From 34e01b5ce389eab9827fcc95512c273aaa819d01 Mon Sep 17 00:00:00 2001 From: 7000pctAUTO Date: Mon, 2 Feb 2026 19:57:10 +0000 Subject: [PATCH] Add CLI commands module --- src/cli/commands.py | 533 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 533 insertions(+) create mode 100644 src/cli/commands.py diff --git a/src/cli/commands.py b/src/cli/commands.py new file mode 100644 index 0000000..363a3ea --- /dev/null +++ b/src/cli/commands.py @@ -0,0 +1,533 @@ +"""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')}")