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
4 changes: 3 additions & 1 deletion src/apiup/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ def main() -> None:
print(f" Spec : {cfg.spec}")
print(f" Mode : {cfg.mode}")
print(f" Listen: http://{cfg.host}:{cfg.port}")
print(f" Docs : http://{cfg.host}:{cfg.port}/docs")
print(f" Spec : http://{cfg.host}:{cfg.port}/spec.json")
print(f" Routes: {len(routes)}")
print()
for r in routes:
Expand All @@ -84,7 +86,7 @@ def main() -> None:
# ── Build + serve ────────────────────────────────────────────────────────
from apiup.server import build_mock_app, serve

app = build_mock_app(routes, spec)
app = build_mock_app(routes, spec, cfg.spec)
serve(app, host=cfg.host, port=cfg.port)


Expand Down
61 changes: 50 additions & 11 deletions src/apiup/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,56 @@
import json
import re
import sys
from pathlib import Path
from typing import Any

from apiup.mock import extract_mock_response
from apiup.spec import Route

try:
from litestar import Litestar
from litestar import Litestar, get
from litestar.handlers import HTTPRouteHandler
from litestar.response import Response as LitestarResponse
except ImportError: # pragma: no cover
Litestar = None # type: ignore[assignment,misc]
HTTPRouteHandler = None # type: ignore[assignment,misc]
LitestarResponse = None # type: ignore[assignment]
get = None # type: ignore[assignment]

# OpenAPI {param} → Litestar {param:str}
_PARAM_RE = re.compile(r"\{(\w+)\}")

# Status codes that must not have a response body
_NO_BODY_CODES = frozenset({204, 304})

_SWAGGER_HTML = """<!DOCTYPE html>
<html>
<head>
<title>apiup — Swagger UI</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist/swagger-ui.css">
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: "/spec.json",
dom_id: "#swagger-ui",
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
layout: "BaseLayout",
deepLinking: true
})
</script>
</body>
</html>"""


def _openapi_path_to_litestar(path: str) -> str:
"""Convert /skills/{skillId} → /skills/{skillId:str}."""
return _PARAM_RE.sub(r"{\1:str}", path)


def _make_handler(body: Any, status_code: int) -> Any:
"""Return a handler closed over body+status — no mutable defaults."""
if status_code in _NO_BODY_CODES:
# No-body response — return None, Litestar sends empty 204/304

async def _handler() -> None:
return None
else:
Expand All @@ -52,16 +72,36 @@ async def _handler() -> Any: # type: ignore[misc]
return _handler


def build_mock_app(routes: list[Route], spec: dict[str, Any]) -> Any:
"""Return a Litestar ASGI app with one handler per spec route."""
def build_mock_app(routes: list[Route], spec: dict[str, Any], spec_path: str) -> Any:
"""Return a Litestar ASGI app with mock handlers + swagger UI."""
if Litestar is None:
print(
"ERROR: litestar not installed.\nRun: pip install 'litestar[standard]'",
file=sys.stderr,
)
sys.exit(2)

handlers = []
# ── built-in routes ────────────────────────────────────────────────────
spec_bytes: bytes = Path(spec_path).read_bytes()

@get("/spec.json", media_type="application/json")
async def serve_spec() -> LitestarResponse: # type: ignore[return]
return LitestarResponse(
content=spec_bytes,
status_code=200,
media_type="application/json",
)

@get("/docs", media_type="text/html")
async def serve_docs() -> LitestarResponse: # type: ignore[return]
return LitestarResponse(
content=_SWAGGER_HTML.encode(),
status_code=200,
media_type="text/html",
)

# ── mock route handlers ────────────────────────────────────────────────
handlers: list[Any] = [serve_spec, serve_docs]

for route in routes:
status_code, body = extract_mock_response(route.responses, spec)
Expand All @@ -79,7 +119,6 @@ def build_mock_app(routes: list[Route], spec: dict[str, Any]) -> Any:


def serve(app: Any, host: str, port: int) -> None:
"""Start uvicorn with the given app."""
try:
import uvicorn
except ImportError:
Expand Down
Loading