"""CLI commands for OpenAPI Mock Generator.""" import json import subprocess import sys from pathlib import Path import click import yaml from src.core.generator import ResponseGenerator from src.core.parser import OpenAPIParser from src.core.server import MockServer @click.group() @click.version_option(version="0.1.0") @click.option( "--spec-file", default="openapi.yaml", help="Path to OpenAPI specification file", envvar="MOCK_SPEC_FILE", ) @click.pass_context def cli(ctx: click.Context, spec_file: str) -> None: """OpenAPI Mock Generator - Generate realistic mock API responses from OpenAPI specs. A CLI tool for testing API clients without requiring a full backend. """ ctx.ensure_object(dict) ctx.obj["spec_file"] = spec_file @cli.command() @click.option( "--port", default=8080, type=int, help="Port to run the mock server on", envvar="MOCK_PORT", ) @click.option( "--delay", default=0, type=int, help="Response delay in milliseconds", envvar="MOCK_DELAY", ) @click.option( "--fuzzing", is_flag=True, default=False, help="Enable fuzzing mode for edge case testing", ) @click.option( "--seed", type=int, default=None, help="Random seed for reproducible responses", ) @click.option( "--validate-only", is_flag=True, default=False, help="Only validate the OpenAPI spec without starting the server", ) @click.pass_context def serve( ctx: click.Context, port: int, delay: int, fuzzing: bool, seed: int | None, validate_only: bool, ) -> None: """Start the mock server and serve API responses.""" spec_file = ctx.obj["spec_file"] if not Path(spec_file).exists(): click.echo(f"Error: OpenAPI spec file not found: {spec_file}", err=True) sys.exit(1) parser = OpenAPIParser(spec_file) try: parser.load() errors = parser.validate_spec() if errors: click.echo("Validation errors found:", err=True) for error in errors: click.echo(f" - {error}", err=True) sys.exit(1) click.echo(f"OpenAPI spec loaded successfully (version: {parser.version})") click.echo(f"Paths defined: {len(parser.get_paths())}") click.echo(f"Schemas defined: {len(parser.get_schemas())}") if validate_only: click.echo("Validation complete. Spec is valid.") return except Exception as e: click.echo(f"Error loading spec: {e}", err=True) sys.exit(1) try: server = MockServer( spec_file=spec_file, port=port, delay=delay, fuzzing=fuzzing, seed=seed, ) server.start(blocking=True) except Exception as e: click.echo(f"Error starting server: {e}", err=True) sys.exit(1) @cli.command() @click.argument("path", required=False) @click.argument("method", required=False, type=click.Choice(["get", "post", "put", "delete", "patch"])) @click.option( "--status-code", default=200, type=int, help="HTTP status code for response", ) @click.option( "--seed", type=int, default=None, help="Random seed for reproducible generation", ) @click.option( "--format", "output_format", type=click.Choice(["json", "yaml"]), default="json", help="Output format", ) @click.pass_context def generate( ctx: click.Context, path: str | None, method: str | None, status_code: int, seed: int | None, output_format: str, ) -> None: """Generate a mock response without starting the server. Generates a response for a given path/method or lists available endpoints. """ spec_file = ctx.obj["spec_file"] if not Path(spec_file).exists(): click.echo(f"Error: OpenAPI spec file not found: {spec_file}", err=True) sys.exit(1) parser = OpenAPIParser(spec_file) try: parser.load() parser.validate_spec() except Exception as e: click.echo(f"Error loading spec: {e}", err=True) sys.exit(1) if path is None: click.echo("Available endpoints:") for p, path_item in parser.get_paths().items(): methods = [m.upper() for m in ["get", "post", "put", "delete", "patch"] if m in path_item] click.echo(f" {p}: {', '.join(methods)}") return method = method or "get" path_lower = path.lower() paths = parser.get_paths() matched_path = None for p in paths: if p.lower() == path_lower: matched_path = p break if matched_path is None: click.echo(f"Error: Path '{path}' not found in spec", err=True) sys.exit(1) if method.lower() not in paths[matched_path]: click.echo(f"Error: Method '{method}' not defined for path '{path}'", err=True) sys.exit(1) generator = ResponseGenerator(seed=seed) generator.set_resolved_schemas(parser.get_schemas()) response_schema = parser.get_response_schema(matched_path, method.lower(), str(status_code)) if response_schema is None: response_schema = parser.get_response_schema(matched_path, method.lower(), "200") if response_schema is None: click.echo(f"Warning: No response schema found for {method.upper()} {path}", err=True) response = {"status_code": status_code, "message": "No schema defined"} else: body = generator.generate(response_schema) response = {"status_code": status_code, "body": body} if output_format == "json": click.echo(json.dumps(response, indent=2)) else: click.echo(yaml.dump(response, default_flow_style=False)) @cli.command() @click.argument("path", required=False) @click.argument("method", required=False, type=click.Choice(["get", "post", "put", "delete", "patch"])) @click.pass_context def validate(ctx: click.Context, path: str | None, method: str | None) -> None: """Validate the OpenAPI specification or specific endpoint.""" spec_file = ctx.obj["spec_file"] if not Path(spec_file).exists(): click.echo(f"Error: OpenAPI spec file not found: {spec_file}", err=True) sys.exit(1) parser = OpenAPIParser(spec_file) try: parser.load() errors = parser.validate_spec() if errors: click.echo("Validation errors:", err=True) for error in errors: click.echo(f" - {error}", err=True) sys.exit(1) click.echo("OpenAPI specification is valid!") click.echo(f"Version: {parser.version}") click.echo(f"Paths: {len(parser.get_paths())}") click.echo(f"Schemas: {len(parser.get_schemas())}") click.echo(f"Servers: {len(parser.get_servers())}") if path: method = method or "get" paths = parser.get_paths() if path not in paths: click.echo(f"Error: Path '{path}' not found", err=True) sys.exit(1) if method.lower() not in paths[path]: click.echo(f"Error: Method '{method}' not defined for path", err=True) sys.exit(1) operation = paths[path][method.lower()] click.echo(f"\nOperation: {method.upper()} {path}") click.echo(f" Summary: {operation.get('summary', 'N/A')}") click.echo(f" Description: {operation.get('description', 'N/A')}") params = operation.get("parameters", []) click.echo(f" Parameters: {len(params)}") except Exception as e: click.echo(f"Error: {e}", err=True) sys.exit(1) @cli.command() @click.argument("path", required=False) @click.option( "--method", type=click.Choice(["get", "post", "put", "delete", "patch"]), help="HTTP method", ) @click.option( "--query", multiple=True, help="Query parameters (key=value)", ) @click.option( "--header", multiple=True, help="Headers (key=value)", ) @click.option( "--body", help="Request body (JSON string)", ) @click.option( "--fuzz", is_flag=True, default=False, help="Use fuzzing mode for edge case testing", ) @click.pass_context def test( ctx: click.Context, path: str | None, method: str | None, query: tuple[str, ...], header: tuple[str, ...], body: str | None, fuzz: bool, ) -> None: """Test a mock endpoint with custom request.""" spec_file = ctx.obj["spec_file"] if not Path(spec_file).exists(): click.echo(f"Error: OpenAPI spec file not found: {spec_file}", err=True) sys.exit(1) if not path: click.echo("Error: Path is required for test command", err=True) sys.exit(1) method = method or "get" method_upper = method.upper() parser = OpenAPIParser(spec_file) try: parser.load() parser.validate_spec() except Exception as e: click.echo(f"Error loading spec: {e}", err=True) sys.exit(1) from src.core.server import MockServer server = MockServer( spec_file=spec_file, port=0, fuzzing=fuzz, ) server.load_spec() query_params = {} for q in query: if "=" in q: key, value = q.split("=", 1) query_params[key] = value headers = {} for h in header: if "=" in h: key, value = h.split("=", 1) headers[key.lower()] = value request_body = None if body: try: request_body = json.loads(body) except json.JSONDecodeError: click.echo(f"Error: Invalid JSON body: {body}", err=True) sys.exit(1) try: paths = parser.get_paths() matched_path = None for p in paths: if p.lower() == path.lower(): matched_path = p break if matched_path is None: click.echo(f"Error: Path '{path}' not found in spec", err=True) sys.exit(1) path_item = paths[matched_path] if method.lower() not in path_item: click.echo(f"Error: Method '{method}' not defined for path", err=True) sys.exit(1) response = server._handle_request( method_upper, matched_path, headers, query_params, request_body, ) click.echo(f"Status: {response['status_code']}") click.echo("Response:") click.echo(json.dumps(response.get("body"), indent=2)) except Exception as e: click.echo(f"Error: {e}", err=True) sys.exit(1) @cli.command() @click.option( "--image-name", default="openapi-mock-generator", help="Docker image name", ) @click.option( "--port", default=8080, type=int, help="Port to expose", ) @click.option( "--spec-file", default="openapi.yaml", help="OpenAPI spec file path (inside container)", ) @click.option( "--build/--no-build", default=True, help="Build the Docker image", ) @click.pass_context def docker( ctx: click.Context, image_name: str, port: int, spec_file: str, build: bool, ) -> None: """Manage Docker container for the mock server.""" if build: click.echo("Building Docker image...") if not Path("Dockerfile").exists(): click.echo("Error: Dockerfile not found in current directory", err=True) sys.exit(1) try: result = subprocess.run( ["docker", "build", "-t", image_name, "."], capture_output=True, text=True, ) if result.returncode != 0: click.echo(f"Docker build failed:\n{result.stderr}", err=True) sys.exit(1) click.echo("Docker image built successfully!") except FileNotFoundError: click.echo("Error: Docker is not installed or not in PATH", err=True) sys.exit(1) click.echo("Run the container with:") click.echo( f" docker run -p {port}:8080 -v $(pwd)/openapi.yaml:/app/openapi.yaml:ro {image_name}" ) click.echo("\nOr with custom spec:") click.echo( f" docker run -p {port}:8080 -v /path/to/spec.yaml:/app/openapi.yaml:ro {image_name}" ) @cli.command() @click.pass_context def routes(ctx: click.Context) -> None: """List all available routes from the OpenAPI spec.""" spec_file = ctx.obj["spec_file"] if not Path(spec_file).exists(): click.echo(f"Error: OpenAPI spec file not found: {spec_file}", err=True) sys.exit(1) parser = OpenAPIParser(spec_file) try: parser.load() except Exception as e: click.echo(f"Error loading spec: {e}", err=True) sys.exit(1) paths = parser.get_paths() if not paths: click.echo("No paths defined in the OpenAPI spec.") return click.echo("Available routes:") for path, path_item in sorted(paths.items()): methods = [] for method in ["get", "post", "put", "delete", "patch"]: if method in path_item: op = path_item[method] summary = op.get("summary", "") methods.append(f"{method.upper()}({summary})" if summary else method.upper()) click.echo(f" {path}") for method in methods: click.echo(f" - {method}") @cli.command() @click.pass_context def info(ctx: click.Context) -> None: """Display information about the OpenAPI specification.""" spec_file = ctx.obj["spec_file"] if not Path(spec_file).exists(): click.echo(f"Error: OpenAPI spec file not found: {spec_file}", err=True) sys.exit(1) parser = OpenAPIParser(spec_file) try: spec = parser.load() parser.validate_spec() except Exception as e: click.echo(f"Error loading spec: {e}", err=True) sys.exit(1) click.echo("OpenAPI Specification Info") click.echo("=" * 40) click.echo(f"Version: {parser.version}") click.echo(f"Title: {spec.get('info', {}).get('title', 'N/A')}") click.echo(f"Version: {spec.get('info', {}).get('version', 'N/A')}") click.echo(f"Description: {spec.get('info', {}).get('description', 'N/A')}") click.echo("") click.echo(f"Paths: {len(parser.get_paths())}") click.echo(f"Schemas: {len(parser.get_schemas())}") click.echo(f"Servers: {len(parser.get_servers())}") servers = parser.get_servers() if servers: click.echo("") click.echo("Servers:") for server in servers: click.echo(f" - {server.get('url', 'N/A')}") if server.get('description'): click.echo(f" {server.get('description')}")