From 2d071073717c25f8ee86de04bd894f0c93b32e00 Mon Sep 17 00:00:00 2001 From: Robert Halter Date: Fri, 20 Mar 2026 18:55:14 +0100 Subject: [PATCH] Feature: OpenAPI spec syntax validation (--validate flag) --- pyproject.toml | 3 +++ src/apiup/cli.py | 17 ++++++++++++--- src/apiup/validate.py | 51 +++++++++++++++++++++++++++++++++++++++++++ tests/test_apiup.py | 26 ++++++++++++++++++++++ 4 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 src/apiup/validate.py diff --git a/pyproject.toml b/pyproject.toml index a7c5bf0..211ad62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,9 @@ dependencies = [ ] [project.optional-dependencies] +validate = [ + "openapi-spec-validator>=0.7", +] dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", diff --git a/src/apiup/cli.py b/src/apiup/cli.py index 8c13fbb..4dd4e07 100644 --- a/src/apiup/cli.py +++ b/src/apiup/cli.py @@ -5,6 +5,7 @@ from __future__ import annotations import argparse +import sys from apiup import __version__ from apiup.config import load_config @@ -16,22 +17,26 @@ def _build_parser() -> argparse.ArgumentParser: prog="apiup", description=( "Start a local mock REST API server from an OpenAPI 3.x spec.\n" - "Convention: reads ~/.openapi/spec.yaml by default." + "Convention: reads ~/.openapi/spec.json by default." ), formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "Examples:\n" - " apiup # mock server from ~/.openapi/spec.yaml\n" + " apiup # mock server from ~/.openapi/spec.json\n" " apiup --spec ./orders.yaml # custom spec\n" " apiup --port 9000 # custom port\n" " apiup --list # list routes, no server\n" + " apiup --validate # validate spec and exit\n" ), ) - p.add_argument("--spec", default=None, help="Path to OpenAPI spec (.yaml or .json)") + p.add_argument("--spec", default=None, help="Path to OpenAPI spec (.json or .yaml)") p.add_argument("--port", type=int, default=None, help="Port to listen on (default: 8080)") p.add_argument("--host", default=None, help="Host to bind (default: 127.0.0.1)") p.add_argument("--mode", choices=["mock"], default=None, help="Server mode (default: mock)") p.add_argument("--list", action="store_true", help="List routes and exit (no server)") + p.add_argument( + "--validate", action="store_true", help="Validate spec and exit (requires apiup[validate])" + ) p.add_argument("--version", action="version", version=f"%(prog)s {__version__}") return p @@ -51,6 +56,12 @@ def main() -> None: routes = extract_routes(spec) title, version = spec_info(spec) + if args.validate: + from apiup.validate import validate_spec + + ok = validate_spec(spec, cfg.spec) + sys.exit(0 if ok else 1) + if args.list: print(f"\nRoutes in: {cfg.spec}\n") for r in routes: diff --git a/src/apiup/validate.py b/src/apiup/validate.py new file mode 100644 index 0000000..f0443d2 --- /dev/null +++ b/src/apiup/validate.py @@ -0,0 +1,51 @@ +""" +OpenAPI spec validation using openapi-spec-validator (optional dependency). +""" + +from __future__ import annotations + +from typing import Any + + +def validate_spec(spec: dict[str, Any], spec_path: str) -> bool: + """Validate spec against OpenAPI 3.x schema. + + Returns True if valid, False if invalid. + Prints results to stdout. + If openapi-spec-validator is not installed, warns and returns True (non-blocking). + """ + try: + from openapi_spec_validator import validate + from openapi_spec_validator.readers import read_from_filename + except ImportError: + print( + " ⚠ openapi-spec-validator not installed — skipping deep validation.\n" + " Run: pip install 'apiup[validate]' to enable." + ) + return True + + openapi_version = spec.get("openapi", spec.get("swagger", "?")) + title, version = ( + spec.get("info", {}).get("title", "?"), + spec.get("info", {}).get("version", "?"), + ) + + print(f"\n Validating: {spec_path}") + print(f" OpenAPI : {openapi_version}") + print(f" Title : {title} v{version}") + print() + + try: + spec_dict, _ = read_from_filename(spec_path) + validate(spec_dict) + from apiup.spec import extract_routes + + routes = extract_routes(spec) + print(f" ✓ spec valid ({len(routes)} route(s))\n") + return True + except Exception as exc: + print(" ✗ spec invalid:\n") + for line in str(exc).splitlines(): + print(f" {line}") + print() + return False diff --git a/tests/test_apiup.py b/tests/test_apiup.py index c0ea43a..d498ea7 100644 --- a/tests/test_apiup.py +++ b/tests/test_apiup.py @@ -179,3 +179,29 @@ def test_default_spec_falls_back_to_yaml(tmp_path: Path, monkeypatch: pytest.Mon config = load_config() assert config.spec.endswith("spec.yaml") + + +# ── validate.py tests ──────────────────────────────────────────────────────── + + +def test_validate_no_validator_installed( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture +) -> None: + """validate_spec returns True and warns when openapi-spec-validator is missing.""" + import builtins + + real_import = builtins.__import__ + + def mock_import(name: str, *args, **kwargs): # type: ignore[no-untyped-def] + if name.startswith("openapi_spec_validator"): + raise ImportError("not installed") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", mock_import) + + from apiup.validate import validate_spec + + result = validate_spec(MINIMAL_SPEC, "/fake/spec.yaml") + assert result is True + captured = capsys.readouterr() + assert "not installed" in captured.out