Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ dependencies = [
]

[project.optional-dependencies]
validate = [
"openapi-spec-validator>=0.7",
]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
Expand Down
17 changes: 14 additions & 3 deletions src/apiup/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from __future__ import annotations

import argparse
import sys

from apiup import __version__
from apiup.config import load_config
Expand All @@ -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

Expand All @@ -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:
Expand Down
51 changes: 51 additions & 0 deletions src/apiup/validate.py
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions tests/test_apiup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading