This commit is contained in:
533
src/cli/commands.py
Normal file
533
src/cli/commands.py
Normal file
@@ -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')}")
|
||||
Reference in New Issue
Block a user