chore(release): 0.0.7
Squash-merge lib-standardisation: adopt repo standards across packaging, tooling, docs, and CI. - standardize pyproject.toml (canonical packaging, lint config, extras, GSDFC pydoclint) - add py.typed marker, modernize type hints and Google-style docstrings - nest generated docs under docs/lib and docs/mcp; add hand-written wiki - adopt standard Type A PyPI CI pipeline (tag==version guard + quality gate) - promote generated CHANGELOG.md; bump version to 0.0.7
This commit is contained in:
@@ -1,9 +1,19 @@
|
||||
from . import app as app
|
||||
from . import binder as binder
|
||||
from . import loader as loader
|
||||
from . import client as client
|
||||
from . import errors as errors
|
||||
from . import codegen as codegen
|
||||
from . import codegen_routes as codegen_routes
|
||||
from . import errors as errors
|
||||
from . import loader as loader
|
||||
from . import security as security
|
||||
|
||||
__all__ = ["app", "binder", "loader", "client", "errors", "codegen", "codegen_routes"]
|
||||
__all__ = [
|
||||
"app",
|
||||
"binder",
|
||||
"loader",
|
||||
"client",
|
||||
"errors",
|
||||
"codegen",
|
||||
"codegen_routes",
|
||||
"security",
|
||||
]
|
||||
|
||||
@@ -34,6 +34,8 @@ Notes:
|
||||
|
||||
import os
|
||||
import re
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
@@ -46,8 +48,10 @@ _env_pattern = re.compile(r"\{(\w+)\}")
|
||||
|
||||
def _resolve_env(value: str) -> str:
|
||||
"""Replace {ENV_VAR} placeholders with values from os.environ."""
|
||||
|
||||
def _replace(m: re.Match) -> str:
|
||||
return os.environ.get(m.group(1), m.group(0))
|
||||
|
||||
return _env_pattern.sub(_replace, value)
|
||||
|
||||
|
||||
@@ -66,6 +70,23 @@ class OpenAPIFirstApp(FastAPI):
|
||||
"""
|
||||
FastAPI application enforcing OpenAPI-first design.
|
||||
|
||||
Args:
|
||||
openapi_path (str):
|
||||
Filesystem path to the OpenAPI 3.x specification file. This
|
||||
specification is treated as the authoritative API contract.
|
||||
routes_module (ModuleType):
|
||||
Python module containing handler functions whose names correspond
|
||||
exactly to OpenAPI ``operationId`` values.
|
||||
**fastapi_kwargs (Any):
|
||||
Additional keyword arguments passed directly to
|
||||
``fastapi.FastAPI`` (e.g., title, version, middleware, lifespan
|
||||
handlers).
|
||||
|
||||
Raises:
|
||||
OpenAPIFirstError:
|
||||
If the OpenAPI specification is invalid, or if any declared
|
||||
``operationId`` does not have a corresponding handler function.
|
||||
|
||||
Notes:
|
||||
**Responsibilities:**
|
||||
|
||||
@@ -88,7 +109,7 @@ class OpenAPIFirstApp(FastAPI):
|
||||
|
||||
Example:
|
||||
```python
|
||||
from openapi_first import OpenAPIFirstApp
|
||||
from openapi_first.app import OpenAPIFirstApp
|
||||
import app.routes as routes
|
||||
|
||||
app = OpenAPIFirstApp(
|
||||
@@ -103,29 +124,9 @@ class OpenAPIFirstApp(FastAPI):
|
||||
self,
|
||||
*,
|
||||
openapi_path: str,
|
||||
routes_module,
|
||||
**fastapi_kwargs,
|
||||
routes_module: ModuleType,
|
||||
**fastapi_kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Initialize the application.
|
||||
|
||||
Args:
|
||||
openapi_path (str):
|
||||
Filesystem path to the OpenAPI 3.x specification file. This
|
||||
specification is treated as the authoritative API contract.
|
||||
routes_module (module):
|
||||
Python module containing handler functions whose names correspond
|
||||
exactly to OpenAPI ``operationId`` values.
|
||||
**fastapi_kwargs (Any):
|
||||
Additional keyword arguments passed directly to
|
||||
``fastapi.FastAPI`` (e.g., title, version, middleware, lifespan
|
||||
handlers).
|
||||
|
||||
Raises:
|
||||
OpenAPIFirstError:
|
||||
If the OpenAPI specification is invalid, or if any declared
|
||||
``operationId`` does not have a corresponding handler function.
|
||||
"""
|
||||
# Initialize FastAPI normally
|
||||
super().__init__(**fastapi_kwargs)
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from fastapi import FastAPI
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
class OpenAPIFirstApp(FastAPI):
|
||||
def __init__(self, *, openapi_path: str, routes_module: Any, **fastapi_kwargs: Any) -> None: ...
|
||||
def __init__(
|
||||
self, *, openapi_path: str, routes_module: Any, **fastapi_kwargs: Any
|
||||
) -> None: ...
|
||||
|
||||
@@ -32,31 +32,33 @@ Notes:
|
||||
- Interpret OpenAPI semantics beyond routing metadata.
|
||||
"""
|
||||
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from .errors import MissingOperationHandler
|
||||
|
||||
|
||||
def bind_routes(
|
||||
app,
|
||||
spec: dict,
|
||||
routes_module,
|
||||
app: FastAPI,
|
||||
spec: dict[str, Any],
|
||||
routes_module: ModuleType,
|
||||
security_deps: dict[str, list[Any]] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Bind OpenAPI operations to FastAPI routes.
|
||||
|
||||
Args:
|
||||
app (fastapi.FastAPI):
|
||||
app (FastAPI):
|
||||
The FastAPI application instance to which routes will be added.
|
||||
spec (dict):
|
||||
spec (dict[str, Any]):
|
||||
Parsed OpenAPI 3.x specification dictionary.
|
||||
routes_module (module):
|
||||
routes_module (ModuleType):
|
||||
Python module containing handler functions. Each handler's name MUST
|
||||
exactly match an OpenAPI `operationId`.
|
||||
security_deps (dict | None):
|
||||
security_deps (dict[str, list[Any]] | None):
|
||||
Optional mapping of ``METHOD:/path`` → ``list[Depends(...)]``
|
||||
generated from the spec's ``securitySchemes`` and per-operation
|
||||
``security`` fields.
|
||||
@@ -85,8 +87,14 @@ def bind_routes(
|
||||
paths = spec.get("paths", {})
|
||||
security_deps = security_deps or {}
|
||||
|
||||
http_methods = {"get", "put", "post", "delete", "options", "head", "patch", "trace"}
|
||||
|
||||
for path, methods in paths.items():
|
||||
for http_method, operation in methods.items():
|
||||
if http_method.lower() not in http_methods or not isinstance(
|
||||
operation, dict
|
||||
):
|
||||
continue
|
||||
|
||||
operation_id = operation.get("operationId")
|
||||
if not operation_id:
|
||||
@@ -102,7 +110,7 @@ def bind_routes(
|
||||
path=path,
|
||||
method=http_method,
|
||||
operation_id=operation_id,
|
||||
)
|
||||
) from None
|
||||
|
||||
key = f"{http_method.upper()}:{path}"
|
||||
deps = security_deps.get(key, [])
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
from typing import Any, Dict
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
def bind_routes(app: FastAPI, spec: Dict[str, Any], routes_module: Any) -> None: ...
|
||||
def bind_routes(
|
||||
app: FastAPI,
|
||||
spec: dict[str, Any],
|
||||
routes_module: ModuleType,
|
||||
security_deps: dict[str, list[Any]] | None = None,
|
||||
) -> None: ...
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
"""
|
||||
# Summary
|
||||
|
||||
Command-line interface for FastAPI OpenAPI-first scaffolding utilities.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
This CLI bootstraps OpenAPI-first FastAPI applications from versioned,
|
||||
bundled templates packaged with the library.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from importlib import resources
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_TEMPLATE = "health_app"
|
||||
|
||||
@@ -52,14 +49,15 @@ def copy_template(template: str, target_dir: Path) -> None:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
root = resources.files("openapi_first.templates")
|
||||
src = root / template
|
||||
|
||||
if not src.exists():
|
||||
if template not in available_templates():
|
||||
raise FileNotFoundError(
|
||||
f"Template '{template}' not found. "
|
||||
f"Available templates: {', '.join(available_templates())}"
|
||||
)
|
||||
|
||||
src = root / template
|
||||
|
||||
with resources.as_file(src) as path:
|
||||
shutil.copytree(path, target_dir, dirs_exist_ok=True)
|
||||
|
||||
@@ -133,6 +131,24 @@ def main() -> None:
|
||||
default="models",
|
||||
help="Python module path for models (default: models)",
|
||||
)
|
||||
routes_parser.add_argument(
|
||||
"--inject-request",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Prepend `request: Request` to every generated handler",
|
||||
)
|
||||
routes_parser.add_argument(
|
||||
"--package",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Emit one `<resource>/routes.py` package per resource",
|
||||
)
|
||||
routes_parser.add_argument(
|
||||
"--template-style",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Emit async handlers with a `body` parameter (template convention)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -155,6 +171,9 @@ def main() -> None:
|
||||
output_dir=Path(args.output_dir),
|
||||
use_models=args.use_models,
|
||||
models_module=args.models_module,
|
||||
inject_request=args.inject_request,
|
||||
package=args.package,
|
||||
template_style=args.template_style,
|
||||
)
|
||||
print(f"Generated {len(files)} route file(s):")
|
||||
for f in files:
|
||||
@@ -168,11 +187,11 @@ def main() -> None:
|
||||
# Handle the case where someone uses the old CLI style (openapi-first template path)
|
||||
# argparse with subparsers might not automatically handle this if positional args are passed
|
||||
# but let's assume we want to encourage the new style.
|
||||
|
||||
|
||||
# If no command was provided but positional args were, they might be for scaffolding
|
||||
# This is a bit tricky with argparse subparsers.
|
||||
# This is a bit tricky with argparse subparsers.
|
||||
# For simplicity, let's just support the new explicit commands.
|
||||
|
||||
|
||||
if args.command is None and not any(vars(args).values()):
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
7
openapi_first/cli.pyi
Normal file
7
openapi_first/cli.pyi
Normal file
@@ -0,0 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_TEMPLATE: str
|
||||
|
||||
def available_templates() -> list[str]: ...
|
||||
def copy_template(template: str, target_dir: Path) -> None: ...
|
||||
def main() -> None: ...
|
||||
@@ -31,7 +31,8 @@ Notes:
|
||||
- This module intentionally does NOT: Generate client code, validate request/response schemas, deserialize responses, retry requests, implement authentication helpers, or assume non-2xx responses are failures.
|
||||
"""
|
||||
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
@@ -49,6 +50,20 @@ class OpenAPIClient:
|
||||
"""
|
||||
OpenAPI-first HTTP client (`httpx`-based).
|
||||
|
||||
Args:
|
||||
spec (dict[str, Any]):
|
||||
Parsed OpenAPI 3.x specification.
|
||||
base_url (str | None):
|
||||
Base URL of the target service. If omitted, the first entry in the
|
||||
OpenAPI `servers` list is used.
|
||||
client (httpx.Client | None):
|
||||
Optional preconfigured httpx client instance.
|
||||
|
||||
Raises:
|
||||
OpenAPIClientError:
|
||||
If no servers are defined, spec has no paths, operationIds are
|
||||
missing/duplicate, or required parameters are missing.
|
||||
|
||||
Notes:
|
||||
**Responsibilities:**
|
||||
|
||||
@@ -88,29 +103,14 @@ class OpenAPIClient:
|
||||
def __init__(
|
||||
self,
|
||||
spec: dict[str, Any],
|
||||
base_url: Optional[str] = None,
|
||||
client: Optional[httpx.Client] = None,
|
||||
base_url: str | None = None,
|
||||
client: httpx.Client | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the OpenAPI client.
|
||||
|
||||
Args:
|
||||
spec (dict[str, Any]):
|
||||
Parsed OpenAPI 3.x specification.
|
||||
base_url (str, optional):
|
||||
Base URL of the target service. If omitted, the first entry in the OpenAPI `servers` list is used.
|
||||
client (httpx.Client, optional):
|
||||
Optional preconfigured httpx client instance.
|
||||
|
||||
Raises:
|
||||
OpenAPIClientError:
|
||||
If no servers are defined, spec has no paths, operationIds are missing/duplicate, or required parameters are missing.
|
||||
"""
|
||||
self.spec = spec
|
||||
self.base_url = base_url or self._resolve_base_url(spec)
|
||||
self.client = client or httpx.Client(base_url=self.base_url)
|
||||
|
||||
self._operations: Dict[str, Callable[..., httpx.Response]] = {}
|
||||
self._operations: dict[str, Callable[..., httpx.Response]] = {}
|
||||
self._build_operations()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -123,7 +123,7 @@ class OpenAPIClient:
|
||||
except KeyError:
|
||||
raise AttributeError(f"No such operationId: {name}") from None
|
||||
|
||||
def operations(self) -> Dict[str, Callable[..., httpx.Response]]:
|
||||
def operations(self) -> dict[str, Callable[..., httpx.Response]]:
|
||||
return dict(self._operations)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -149,7 +149,13 @@ class OpenAPIClient:
|
||||
for path, path_item in paths.items():
|
||||
for method, operation in path_item.items():
|
||||
if method.lower() not in {
|
||||
"get", "post", "put", "patch", "delete", "head", "options"
|
||||
"get",
|
||||
"post",
|
||||
"put",
|
||||
"patch",
|
||||
"delete",
|
||||
"head",
|
||||
"options",
|
||||
}:
|
||||
continue
|
||||
|
||||
@@ -181,11 +187,11 @@ class OpenAPIClient:
|
||||
|
||||
def call(
|
||||
*,
|
||||
path_params: Optional[dict[str, Any]] = None,
|
||||
query: Optional[dict[str, Any]] = None,
|
||||
headers: Optional[dict[str, str]] = None,
|
||||
body: Optional[Any] = None,
|
||||
timeout: Optional[float] = None,
|
||||
path_params: dict[str, Any] | None = None,
|
||||
query: dict[str, Any] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
body: Any | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> httpx.Response:
|
||||
url = self._build_url(path, path_params or {})
|
||||
|
||||
@@ -228,9 +234,7 @@ class OpenAPIClient:
|
||||
try:
|
||||
formatted_path = path.format(**path_params)
|
||||
except KeyError as exc:
|
||||
raise OpenAPIClientError(
|
||||
f"Missing path parameter: {exc.args[0]}"
|
||||
) from exc
|
||||
raise OpenAPIClientError(f"Missing path parameter: {exc.args[0]}") from exc
|
||||
|
||||
return urljoin(self.base_url, formatted_path.lstrip("/"))
|
||||
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .errors import OpenAPIFirstError
|
||||
|
||||
class OpenAPIClientError(OpenAPIFirstError): ...
|
||||
|
||||
class OpenAPIClient:
|
||||
spec: Dict[str, Any]
|
||||
spec: dict[str, Any]
|
||||
base_url: str
|
||||
client: httpx.Client
|
||||
def __init__(self, spec: Dict[str, Any], base_url: Optional[str] = ..., client: Optional[httpx.Client] = ...) -> None: ...
|
||||
def __init__(
|
||||
self,
|
||||
spec: dict[str, Any],
|
||||
base_url: str | None = ...,
|
||||
client: httpx.Client | None = ...,
|
||||
) -> None: ...
|
||||
def __getattr__(self, name: str) -> Callable[..., httpx.Response]: ...
|
||||
def operations(self) -> Dict[str, Callable[..., httpx.Response]]: ...
|
||||
def operations(self) -> dict[str, Callable[..., httpx.Response]]: ...
|
||||
|
||||
@@ -9,6 +9,7 @@ definitions.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from datamodel_code_generator import (
|
||||
InputFileType,
|
||||
PythonVersion,
|
||||
|
||||
11
openapi_first/codegen.pyi
Normal file
11
openapi_first/codegen.pyi
Normal file
@@ -0,0 +1,11 @@
|
||||
from pathlib import Path
|
||||
|
||||
from .codegen_routes import generate_routes
|
||||
|
||||
def generate_models(
|
||||
spec_path: Path,
|
||||
output_path: Path,
|
||||
pydantic_version: int = ...,
|
||||
) -> None: ...
|
||||
|
||||
__all__ = ["generate_models", "generate_routes"]
|
||||
@@ -1,4 +1,6 @@
|
||||
"""
|
||||
# Summary
|
||||
|
||||
Route handler code generation from OpenAPI specifications.
|
||||
|
||||
This module generates Python route handler stubs from an OpenAPI 3.x
|
||||
@@ -16,13 +18,48 @@ Notes:
|
||||
parent resource (``pets``).
|
||||
- Parameter types and defaults are inferred from the spec.
|
||||
- ``response: Response`` is injected for non-200 success codes.
|
||||
- Resource names are sanitized to valid Python module identifiers,
|
||||
so a path such as ``/fetch-requests`` yields ``fetch_requests.py``.
|
||||
- Passing ``inject_request=True`` prepends ``request: Request`` to
|
||||
every handler (the composition-root / DI convention).
|
||||
- ``multipart/form-data`` and
|
||||
``application/x-www-form-urlencoded`` request bodies expand to one
|
||||
parameter per schema property (``UploadFile`` / ``File`` / ``Form``)
|
||||
instead of an opaque ``payload: dict``.
|
||||
- Passing ``package=True`` writes ``<resource>/routes.py`` (with
|
||||
``__init__.py`` sidecars) instead of a flat ``<resource>.py``.
|
||||
- Passing ``template_style=True`` emits ``async def`` handlers whose
|
||||
JSON body parameter is named ``body`` (matching the standards
|
||||
template) and adds a service-resolution hint comment.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .loader import load_openapi
|
||||
|
||||
_FORM_URLENCODED = "application/x-www-form-urlencoded"
|
||||
_MULTIPART = "multipart/form-data"
|
||||
|
||||
|
||||
def _sanitize_identifier(name: str) -> str:
|
||||
"""Convert an arbitrary path segment into a valid Python module name.
|
||||
|
||||
Args:
|
||||
name (str): Raw resource name (first path segment).
|
||||
|
||||
Returns:
|
||||
str: A valid lower-case Python identifier (e.g. ``fetch-requests``
|
||||
becomes ``fetch_requests``).
|
||||
"""
|
||||
ident = re.sub(r"\W", "_", name).strip("_").lower()
|
||||
if not ident:
|
||||
return "resource"
|
||||
if ident[0].isdigit():
|
||||
ident = f"_{ident}"
|
||||
return ident
|
||||
|
||||
|
||||
def generate_routes(
|
||||
spec_path: Path,
|
||||
@@ -30,26 +67,40 @@ def generate_routes(
|
||||
*,
|
||||
use_models: bool = False,
|
||||
models_module: str = "models",
|
||||
inject_request: bool = False,
|
||||
package: bool = False,
|
||||
template_style: bool = False,
|
||||
) -> list[Path]:
|
||||
"""
|
||||
Generate route handler stubs from an OpenAPI specification.
|
||||
|
||||
Creates one ``<resource>.py`` file per resource in *output_dir*.
|
||||
Resources are derived from the first path segment (e.g. ``/pets``
|
||||
and ``/pets/{id}`` both group under ``pets``).
|
||||
Creates one route module per resource in *output_dir*. Resources are
|
||||
derived from the first path segment (e.g. ``/pets`` and ``/pets/{id}``
|
||||
both group under ``pets``) and sanitized to valid module identifiers.
|
||||
|
||||
Args:
|
||||
spec_path:
|
||||
spec_path (Path):
|
||||
Path to the OpenAPI specification file (YAML or JSON).
|
||||
output_dir:
|
||||
output_dir (Path):
|
||||
Directory where the generated route files are written.
|
||||
Created automatically if it does not exist.
|
||||
use_models:
|
||||
use_models (bool, optional):
|
||||
If ``True``, import Pydantic models from *models_module*
|
||||
for request-body schemas referenced via ``$ref``.
|
||||
models_module:
|
||||
models_module (str, optional):
|
||||
Dotted Python module path from which to import models
|
||||
(e.g. ``"models"``, ``"app.models"``).
|
||||
inject_request (bool, optional):
|
||||
If ``True``, prepend ``request: Request`` to every handler so the
|
||||
stub can later resolve services from ``request.app.state``.
|
||||
package (bool, optional):
|
||||
If ``True``, emit ``<resource>/routes.py`` packages (each with an
|
||||
``__init__.py``) instead of flat ``<resource>.py`` modules.
|
||||
template_style (bool, optional):
|
||||
If ``True``, emit ``async def`` handlers with a JSON body parameter
|
||||
named ``body`` (the standards-template convention) plus a
|
||||
service-resolution hint comment, instead of sync ``def`` handlers
|
||||
taking ``payload``.
|
||||
|
||||
Returns:
|
||||
list[Path]:
|
||||
@@ -66,7 +117,7 @@ def generate_routes(
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Group paths by resource (first non-param path segment)
|
||||
resources: dict[str, list[tuple[str, str, dict]]] = {}
|
||||
resources_map: dict[str, list[tuple[str, str, dict[str, Any]]]] = {}
|
||||
|
||||
paths = spec.get("paths", {})
|
||||
for path, methods in paths.items():
|
||||
@@ -74,27 +125,40 @@ def generate_routes(
|
||||
if not segments:
|
||||
continue
|
||||
resource = segments[0]
|
||||
if resource not in resources:
|
||||
resources[resource] = []
|
||||
if resource not in resources_map:
|
||||
resources_map[resource] = []
|
||||
|
||||
for http_method, operation in methods.items():
|
||||
if http_method.startswith("x-"):
|
||||
continue
|
||||
resources[resource].append((path, http_method, operation))
|
||||
resources_map[resource].append((path, http_method, operation))
|
||||
|
||||
generated_files: list[Path] = []
|
||||
|
||||
for resource in sorted(resources):
|
||||
operations = resources[resource]
|
||||
if package:
|
||||
_ensure_package(output_dir)
|
||||
|
||||
for resource in sorted(resources_map):
|
||||
operations = resources_map[resource]
|
||||
_validate_operations(resource, operations)
|
||||
|
||||
file_path = output_dir / f"{resource}.py"
|
||||
identifier = _sanitize_identifier(resource)
|
||||
if package:
|
||||
resource_dir = output_dir / identifier
|
||||
resource_dir.mkdir(parents=True, exist_ok=True)
|
||||
_ensure_package(resource_dir)
|
||||
file_path = resource_dir / "routes.py"
|
||||
else:
|
||||
file_path = output_dir / f"{identifier}.py"
|
||||
|
||||
content = _generate_resource_file(
|
||||
resource=resource,
|
||||
operations=operations,
|
||||
spec_path=str(spec_path),
|
||||
use_models=use_models,
|
||||
models_module=models_module,
|
||||
inject_request=inject_request,
|
||||
template_style=template_style,
|
||||
)
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
generated_files.append(file_path)
|
||||
@@ -116,7 +180,16 @@ _TYPE_MAP: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
def _validate_operations(resource: str, operations: list[tuple[str, str, dict]]) -> None:
|
||||
def _ensure_package(directory: Path) -> None:
|
||||
"""Create an ``__init__.py`` in *directory* when it is missing."""
|
||||
init = directory / "__init__.py"
|
||||
if not init.exists():
|
||||
init.write_text("", encoding="utf-8")
|
||||
|
||||
|
||||
def _validate_operations(
|
||||
resource: str, operations: list[tuple[str, str, dict[str, Any]]]
|
||||
) -> None:
|
||||
"""Ensure every operation has an operationId."""
|
||||
for path, http_method, operation in operations:
|
||||
if not operation.get("operationId"):
|
||||
@@ -144,10 +217,59 @@ def _get_request_body_schema(operation: dict[str, Any]) -> str | None:
|
||||
schema = media_info.get("schema", {})
|
||||
ref = schema.get("$ref", "")
|
||||
if ref:
|
||||
return ref.rsplit("/", 1)[-1]
|
||||
return str(ref.rsplit("/", 1)[-1])
|
||||
return None
|
||||
|
||||
|
||||
def _get_form_properties(
|
||||
operation: dict[str, Any],
|
||||
) -> tuple[str, dict[str, Any], list[str]] | None:
|
||||
"""Return ``(media_type, properties, required)`` for a form-style body.
|
||||
|
||||
Handles ``multipart/form-data`` and ``application/x-www-form-urlencoded``
|
||||
request bodies whose schema is an inline object with ``properties``.
|
||||
Returns ``None`` for JSON bodies or bodies that use ``$ref``.
|
||||
"""
|
||||
request_body = operation.get("requestBody")
|
||||
if not request_body:
|
||||
return None
|
||||
|
||||
content = request_body.get("content", {})
|
||||
for media_type, media_info in content.items():
|
||||
base = media_type.split(";", 1)[0].strip()
|
||||
if base not in (_MULTIPART, _FORM_URLENCODED):
|
||||
continue
|
||||
schema = media_info.get("schema", {})
|
||||
if "$ref" in schema:
|
||||
return None
|
||||
properties = schema.get("properties")
|
||||
if not isinstance(properties, dict) or not properties:
|
||||
return None
|
||||
return base, properties, list(schema.get("required", []))
|
||||
return None
|
||||
|
||||
|
||||
def _is_binary(schema: dict[str, Any]) -> bool:
|
||||
"""Whether a schema represents an uploaded binary payload."""
|
||||
if schema.get("format") == "binary":
|
||||
return True
|
||||
if schema.get("type") == "array":
|
||||
return _is_binary(schema.get("items", {}))
|
||||
return False
|
||||
|
||||
|
||||
def _form_annotation(schema: dict[str, Any]) -> str:
|
||||
"""Map a form field schema to its Python annotation."""
|
||||
if schema.get("type") == "array" and _is_binary(schema.get("items", {})):
|
||||
return "list[UploadFile]"
|
||||
if _is_binary(schema):
|
||||
return "UploadFile"
|
||||
ref = schema.get("$ref", "")
|
||||
if ref:
|
||||
return str(ref.rsplit("/", 1)[-1])
|
||||
return _resolve_type(schema)
|
||||
|
||||
|
||||
def _get_success_status(operation: dict[str, Any]) -> str:
|
||||
"""Return the primary success status code (200, 201, 204, or default)."""
|
||||
responses = operation.get("responses", {})
|
||||
@@ -159,7 +281,7 @@ def _get_success_status(operation: dict[str, Any]) -> str:
|
||||
return "200"
|
||||
|
||||
|
||||
def _needs_any(operations: list[tuple[str, str, dict]]) -> bool:
|
||||
def _needs_any(operations: list[tuple[str, str, dict[str, Any]]]) -> bool:
|
||||
"""Check if any operation uses a type that requires `from typing import Any`."""
|
||||
for _, _, op in operations:
|
||||
for param in op.get("parameters", []):
|
||||
@@ -176,13 +298,44 @@ def _needs_any(operations: list[tuple[str, str, dict]]) -> bool:
|
||||
|
||||
def _generate_resource_file(
|
||||
resource: str,
|
||||
operations: list[tuple[str, str, dict]],
|
||||
operations: list[tuple[str, str, dict[str, Any]]],
|
||||
spec_path: str,
|
||||
use_models: bool,
|
||||
models_module: str,
|
||||
inject_request: bool = False,
|
||||
template_style: bool = False,
|
||||
) -> str:
|
||||
"""Assemble the full Python source for a single resource file."""
|
||||
op_ids = [op.get("operationId", "") for _, _, op in operations]
|
||||
# Windows paths would inject invalid escapes (e.g. ``\U``) into the
|
||||
# generated docstring; normalize to forward slashes for the header.
|
||||
spec_path = spec_path.replace("\\", "/")
|
||||
|
||||
need_upload = False
|
||||
need_file_form = False
|
||||
need_form = False
|
||||
for _, _, op in operations:
|
||||
form = _get_form_properties(op)
|
||||
if not form:
|
||||
continue
|
||||
_, properties, _ = form
|
||||
for prop_schema in properties.values():
|
||||
if _is_binary(prop_schema):
|
||||
need_upload = True
|
||||
need_file_form = True
|
||||
else:
|
||||
need_form = True
|
||||
|
||||
fastapi_names = ["Response", "HTTPException"]
|
||||
if inject_request:
|
||||
fastapi_names.insert(0, "Request")
|
||||
if need_upload:
|
||||
fastapi_names.append("UploadFile")
|
||||
if need_file_form:
|
||||
fastapi_names.append("File")
|
||||
if need_form:
|
||||
fastapi_names.append("Form")
|
||||
|
||||
lines: list[str] = [
|
||||
f'"""Route handlers for the {resource} resource.',
|
||||
"",
|
||||
@@ -190,7 +343,7 @@ def _generate_resource_file(
|
||||
f'Bind via operationIds: {", ".join(op_ids)}',
|
||||
'"""',
|
||||
"",
|
||||
"from fastapi import Response, HTTPException",
|
||||
f"from fastapi import {', '.join(fastapi_names)}",
|
||||
]
|
||||
|
||||
# Conditional typing import
|
||||
@@ -201,17 +354,31 @@ def _generate_resource_file(
|
||||
if use_models:
|
||||
schemas_needed: set[str] = set()
|
||||
for _, _, op in operations:
|
||||
if _get_form_properties(op):
|
||||
continue
|
||||
schema = _get_request_body_schema(op)
|
||||
if schema:
|
||||
schemas_needed.add(schema)
|
||||
if schemas_needed:
|
||||
lines.append(f"from {models_module} import {', '.join(sorted(schemas_needed))}")
|
||||
lines.append(
|
||||
f"from {models_module} import {', '.join(sorted(schemas_needed))}"
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
|
||||
for path, http_method, operation in operations:
|
||||
lines.append("")
|
||||
lines.extend(_generate_handler(path, http_method, operation, use_models))
|
||||
lines.extend(
|
||||
_generate_handler(
|
||||
path,
|
||||
http_method,
|
||||
operation,
|
||||
use_models,
|
||||
inject_request,
|
||||
resource,
|
||||
template_style,
|
||||
)
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
@@ -222,10 +389,14 @@ def _generate_handler(
|
||||
http_method: str,
|
||||
operation: dict[str, Any],
|
||||
use_models: bool,
|
||||
inject_request: bool = False,
|
||||
resource: str = "",
|
||||
template_style: bool = False,
|
||||
) -> list[str]:
|
||||
"""Build the source lines for a single handler function."""
|
||||
operation_id = operation.get("operationId", "")
|
||||
summary = operation.get("summary", f"{http_method.upper()} {path}")
|
||||
body_param = "body" if template_style else "payload"
|
||||
|
||||
params: list[str] = []
|
||||
path_params: list[str] = []
|
||||
@@ -237,7 +408,6 @@ def _generate_handler(
|
||||
schema: dict[str, Any] = param.get("schema", {})
|
||||
param_type: str = _resolve_type(schema)
|
||||
required: bool = param.get("required", False)
|
||||
description: str = schema.get("description", schema.get("x-description", ""))
|
||||
default_raw = schema.get("default")
|
||||
|
||||
if param_in == "path":
|
||||
@@ -252,16 +422,31 @@ def _generate_handler(
|
||||
query_params.append(f"{name}: {param_type} = None")
|
||||
# header / cookie params could be extended here
|
||||
|
||||
if inject_request:
|
||||
params.append("request: Request")
|
||||
params.extend(path_params)
|
||||
params.extend(query_params)
|
||||
|
||||
# Request body
|
||||
form = _get_form_properties(operation)
|
||||
schema_name = _get_request_body_schema(operation)
|
||||
if operation.get("requestBody"):
|
||||
is_form = False
|
||||
if form:
|
||||
is_form = True
|
||||
_, properties, required_fields = form
|
||||
for field_name, field_schema in properties.items():
|
||||
annotation = _form_annotation(field_schema)
|
||||
is_file = _is_binary(field_schema)
|
||||
factory = "File" if is_file else "Form"
|
||||
if field_name in required_fields:
|
||||
params.append(f"{field_name}: {annotation} = {factory}(...)")
|
||||
else:
|
||||
params.append(f"{field_name}: {annotation} = {factory}(None)")
|
||||
elif operation.get("requestBody"):
|
||||
if use_models and schema_name:
|
||||
params.append(f"payload: {schema_name}")
|
||||
params.append(f"{body_param}: {schema_name}")
|
||||
else:
|
||||
params.append("payload: dict")
|
||||
params.append(f"{body_param}: dict")
|
||||
|
||||
# Inject Response for non-200 success codes
|
||||
success_status = _get_success_status(operation)
|
||||
@@ -271,19 +456,22 @@ def _generate_handler(
|
||||
# Build function body
|
||||
lines: list[str] = []
|
||||
param_str = ", ".join(params)
|
||||
lines.append(f"def {operation_id}({param_str}):")
|
||||
prefix = "async def" if template_style else "def"
|
||||
lines.append(f"{prefix} {operation_id}({param_str}):")
|
||||
lines.append(f' """{summary}')
|
||||
|
||||
# Document parameters
|
||||
doc_params: list[tuple[str, str, str]] = []
|
||||
for param in operation.get("parameters", []):
|
||||
name: str = param.get("name", "")
|
||||
param_in: str = param.get("in", "")
|
||||
schema: dict[str, Any] = param.get("schema", {})
|
||||
param_type: str = _resolve_type(schema)
|
||||
description: str = param.get("description", schema.get("x-description", ""))
|
||||
if param_in in ("path", "query"):
|
||||
doc_params.append((name, param_type, description))
|
||||
doc_name: str = param.get("name", "")
|
||||
doc_in: str = param.get("in", "")
|
||||
doc_schema: dict[str, Any] = param.get("schema", {})
|
||||
doc_type: str = _resolve_type(doc_schema)
|
||||
doc_description: str = param.get(
|
||||
"description", doc_schema.get("x-description", "")
|
||||
)
|
||||
if doc_in in ("path", "query"):
|
||||
doc_params.append((doc_name, doc_type, doc_description))
|
||||
|
||||
if doc_params:
|
||||
lines.append("")
|
||||
@@ -296,17 +484,29 @@ def _generate_handler(
|
||||
else:
|
||||
lines.append(f" {pname} : {ptype}")
|
||||
|
||||
if operation.get("requestBody"):
|
||||
if is_form:
|
||||
_, properties, _ = form # type: ignore[misc]
|
||||
lines.append("")
|
||||
for field_name, field_schema in properties.items():
|
||||
lines.append(f" {field_name} : {_form_annotation(field_schema)}")
|
||||
lines.append(" Multipart form field.")
|
||||
elif operation.get("requestBody"):
|
||||
if use_models and schema_name:
|
||||
lines.append("")
|
||||
lines.append(f" payload : {schema_name}")
|
||||
lines.append(f" {body_param} : {schema_name}")
|
||||
lines.append(" Request body.")
|
||||
else:
|
||||
lines.append("")
|
||||
lines.append(" payload : dict")
|
||||
lines.append(f" {body_param} : dict")
|
||||
lines.append(" Request body.")
|
||||
|
||||
lines.append(' """')
|
||||
if template_style and inject_request:
|
||||
svc_key = f"{_sanitize_identifier(resource)}_svc"
|
||||
lines.append(
|
||||
f" # Resolve the service: "
|
||||
f'request.app.state.db_resources["{svc_key}"]'
|
||||
)
|
||||
lines.append(" raise NotImplementedError")
|
||||
|
||||
return lines
|
||||
|
||||
12
openapi_first/codegen_routes.pyi
Normal file
12
openapi_first/codegen_routes.pyi
Normal file
@@ -0,0 +1,12 @@
|
||||
from pathlib import Path
|
||||
|
||||
def generate_routes(
|
||||
spec_path: Path,
|
||||
output_dir: Path,
|
||||
*,
|
||||
use_models: bool = ...,
|
||||
models_module: str = ...,
|
||||
inject_request: bool = ...,
|
||||
package: bool = ...,
|
||||
template_style: bool = ...,
|
||||
) -> list[Path]: ...
|
||||
@@ -18,6 +18,7 @@ Notes:
|
||||
These errors should normally cause immediate application failure.
|
||||
"""
|
||||
|
||||
|
||||
class OpenAPIFirstError(Exception):
|
||||
"""
|
||||
Base exception for all OpenAPI-first enforcement errors.
|
||||
@@ -31,6 +32,7 @@ class OpenAPIFirstError(Exception):
|
||||
- All exceptions raised by the OpenAPI-first core should inherit
|
||||
from this type.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -38,6 +40,14 @@ class MissingOperationHandler(OpenAPIFirstError):
|
||||
"""
|
||||
Raised when an OpenAPI operation cannot be resolved to a handler.
|
||||
|
||||
Args:
|
||||
path (str):
|
||||
The HTTP path declared in the OpenAPI specification.
|
||||
method (str):
|
||||
The HTTP method (as declared in the OpenAPI spec).
|
||||
operation_id (str | None):
|
||||
The operationId declared in the OpenAPI spec, if present.
|
||||
|
||||
Notes:
|
||||
**Scenarios:**
|
||||
|
||||
@@ -53,26 +63,12 @@ class MissingOperationHandler(OpenAPIFirstError):
|
||||
"""
|
||||
|
||||
def __init__(self, *, path: str, method: str, operation_id: str | None = None):
|
||||
"""
|
||||
Initialize the error.
|
||||
|
||||
Args:
|
||||
path (str):
|
||||
The HTTP path declared in the OpenAPI specification.
|
||||
method (str):
|
||||
The HTTP method (as declared in the OpenAPI spec).
|
||||
operation_id (str, optional):
|
||||
The operationId declared in the OpenAPI spec, if present.
|
||||
"""
|
||||
if operation_id:
|
||||
message = (
|
||||
f"Missing handler for operationId '{operation_id}' "
|
||||
f"({method.upper()} {path})"
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
f"Missing operationId for operation "
|
||||
f"({method.upper()} {path})"
|
||||
)
|
||||
message = f"Missing operationId for operation " f"({method.upper()} {path})"
|
||||
|
||||
super().__init__(message)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional
|
||||
|
||||
class OpenAPIFirstError(Exception): ...
|
||||
|
||||
class MissingOperationHandler(OpenAPIFirstError):
|
||||
def __init__(self, *, path: str, method: str, operation_id: Optional[str] = ...) -> None: ...
|
||||
def __init__(
|
||||
self, *, path: str, method: str, operation_id: str | None = ...
|
||||
) -> None: ...
|
||||
|
||||
@@ -46,6 +46,7 @@ class OpenAPISpecLoadError(OpenAPIFirstError):
|
||||
- This error indicates that the OpenAPI document is unreadable,
|
||||
malformed, or violates the OpenAPI 3.x specification.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Union
|
||||
from typing import Any
|
||||
|
||||
from .errors import OpenAPIFirstError
|
||||
|
||||
class OpenAPISpecLoadError(OpenAPIFirstError): ...
|
||||
|
||||
def load_openapi(path: Union[str, Path]) -> Dict[str, Any]: ...
|
||||
def load_openapi(path: str | Path) -> dict[str, Any]: ...
|
||||
|
||||
0
openapi_first/py.typed
Normal file
0
openapi_first/py.typed
Normal file
@@ -1,17 +1,21 @@
|
||||
"""
|
||||
# Summary
|
||||
|
||||
OpenAPI security scheme parsing and auto-generated auth dependencies.
|
||||
|
||||
Reads `securitySchemes` and per-operation `security` from an OpenAPI spec,
|
||||
resolves `{ENV_VAR}` placeholders in `x-` extension fields, and generates
|
||||
FastAPI dependencies for token validation (e.g., Bearer JWT introspection).
|
||||
This module reads `securitySchemes` and per-operation `security` from an
|
||||
OpenAPI spec, resolves `{ENV_VAR}` placeholders in `x-` extension fields,
|
||||
and generates FastAPI dependencies for token validation (e.g., Bearer JWT
|
||||
introspection).
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, Request, HTTPException
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
_env_pattern = re.compile(r"\{(\w+)\}")
|
||||
@@ -19,14 +23,16 @@ _env_pattern = re.compile(r"\{(\w+)\}")
|
||||
|
||||
def _resolve_env(value: str) -> str:
|
||||
"""Replace {ENV_VAR} placeholders with values from os.environ."""
|
||||
def _replace(m: re.Match) -> str:
|
||||
|
||||
def _replace(m: re.Match[str]) -> str:
|
||||
return os.environ.get(m.group(1), "")
|
||||
|
||||
return _env_pattern.sub(_replace, value)
|
||||
|
||||
|
||||
def _resolve_scheme(scheme: dict) -> dict:
|
||||
def _resolve_scheme(scheme: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Recursively resolve env vars in all string-valued fields of a scheme."""
|
||||
resolved = {}
|
||||
resolved: dict[str, Any] = {}
|
||||
for key, value in scheme.items():
|
||||
if isinstance(value, str):
|
||||
resolved[key] = _resolve_env(value)
|
||||
@@ -37,13 +43,13 @@ def _resolve_scheme(scheme: dict) -> dict:
|
||||
return resolved
|
||||
|
||||
|
||||
def parse_security_schemes(spec: dict) -> dict[str, dict]:
|
||||
def parse_security_schemes(spec: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
"""Extract and resolve environment variables in security schemes."""
|
||||
raw = spec.get("components", {}).get("securitySchemes", {})
|
||||
return {name: _resolve_scheme(scheme) for name, scheme in raw.items()}
|
||||
|
||||
|
||||
def _make_bearer_dependency(introspect_url: str | None) -> Callable:
|
||||
def _make_bearer_dependency(introspect_url: str | None) -> Callable[..., Any]:
|
||||
"""
|
||||
Create a FastAPI dependency that validates a Bearer JWT.
|
||||
|
||||
@@ -76,7 +82,7 @@ def _make_bearer_dependency(introspect_url: str | None) -> Callable:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Authentication service unavailable",
|
||||
)
|
||||
) from None
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
@@ -92,7 +98,9 @@ def _make_bearer_dependency(introspect_url: str | None) -> Callable:
|
||||
return _bearer_dep
|
||||
|
||||
|
||||
def _build_dependency(scheme_name: str, scheme: dict) -> Callable | None:
|
||||
def _build_dependency(
|
||||
scheme_name: str, scheme: dict[str, Any]
|
||||
) -> Callable[..., Any] | None:
|
||||
"""Return a FastAPI dependency callable for *scheme*, or ``None``."""
|
||||
scheme_type = scheme.get("type")
|
||||
|
||||
@@ -106,8 +114,8 @@ def _build_dependency(scheme_name: str, scheme: dict) -> Callable | None:
|
||||
|
||||
|
||||
def make_security_dependencies(
|
||||
spec: dict,
|
||||
security_schemes: dict[str, dict],
|
||||
spec: dict[str, Any],
|
||||
security_schemes: dict[str, dict[str, Any]],
|
||||
) -> dict[str, list[Any]]:
|
||||
"""
|
||||
Build a mapping of ``METHOD:/path`` → list of ``Depends(...)``.
|
||||
|
||||
7
openapi_first/security.pyi
Normal file
7
openapi_first/security.pyi
Normal file
@@ -0,0 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
def parse_security_schemes(spec: dict[str, Any]) -> dict[str, dict[str, Any]]: ...
|
||||
def make_security_dependencies(
|
||||
spec: dict[str, Any],
|
||||
security_schemes: dict[str, dict[str, Any]],
|
||||
) -> dict[str, list[Any]]: ...
|
||||
@@ -25,15 +25,15 @@ Scaffolding via CLI
|
||||
|
||||
Create a new CRUD example service using the bundled template:
|
||||
|
||||
openapi-first crud_app
|
||||
openapi-first scaffold crud_app
|
||||
|
||||
Create the service in a custom directory:
|
||||
|
||||
openapi-first crud_app my-crud-service
|
||||
openapi-first scaffold crud_app my-crud-service
|
||||
|
||||
List all available application templates:
|
||||
|
||||
openapi-first --list
|
||||
openapi-first scaffold --list
|
||||
|
||||
The CLI copies template files verbatim into the target directory.
|
||||
No code is generated or modified beyond the copied scaffold.
|
||||
|
||||
@@ -33,7 +33,7 @@ _items: Dict[int, dict] = {
|
||||
_next_id = 3
|
||||
|
||||
|
||||
def list_items():
|
||||
def list_items() -> list[dict]:
|
||||
"""
|
||||
Return all items in the data store.
|
||||
|
||||
@@ -48,7 +48,7 @@ def list_items():
|
||||
return list(_items.values())
|
||||
|
||||
|
||||
def get_item(item_id: int):
|
||||
def get_item(item_id: int) -> dict:
|
||||
"""
|
||||
Retrieve a single item by ID.
|
||||
|
||||
@@ -68,7 +68,7 @@ def get_item(item_id: int):
|
||||
return _items[item_id]
|
||||
|
||||
|
||||
def create_item(payload: dict):
|
||||
def create_item(payload: dict) -> dict:
|
||||
"""
|
||||
Create a new item in the data store.
|
||||
|
||||
@@ -92,7 +92,7 @@ def create_item(payload: dict):
|
||||
return item
|
||||
|
||||
|
||||
def update_item(item_id: int, payload: dict):
|
||||
def update_item(item_id: int, payload: dict) -> dict:
|
||||
"""
|
||||
Replace an existing item in the data store.
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ from data import (
|
||||
)
|
||||
|
||||
|
||||
def list_items():
|
||||
def list_items() -> list[dict]:
|
||||
"""
|
||||
List all items.
|
||||
|
||||
@@ -44,7 +44,7 @@ def list_items():
|
||||
return _list_items()
|
||||
|
||||
|
||||
def get_item(item_id: int):
|
||||
def get_item(item_id: int) -> dict:
|
||||
"""
|
||||
Retrieve a single item by ID.
|
||||
|
||||
@@ -72,7 +72,7 @@ def get_item(item_id: int):
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
|
||||
def create_item(payload: dict, response: Response):
|
||||
def create_item(payload: dict, response: Response) -> dict:
|
||||
"""
|
||||
Create a new item.
|
||||
|
||||
@@ -83,7 +83,7 @@ def create_item(payload: dict, response: Response):
|
||||
----------
|
||||
payload : dict
|
||||
Item attributes excluding the ``id`` field.
|
||||
response : fastapi.Response
|
||||
response : Response
|
||||
Response object used to set the HTTP status code.
|
||||
|
||||
Returns
|
||||
@@ -96,7 +96,7 @@ def create_item(payload: dict, response: Response):
|
||||
return item
|
||||
|
||||
|
||||
def update_item(item_id: int, payload: dict):
|
||||
def update_item(item_id: int, payload: dict) -> dict:
|
||||
"""
|
||||
Update an existing item.
|
||||
|
||||
@@ -126,7 +126,7 @@ def update_item(item_id: int, payload: dict):
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
|
||||
def delete_item(item_id: int, response: Response):
|
||||
def delete_item(item_id: int, response: Response) -> None:
|
||||
"""
|
||||
Delete an existing item.
|
||||
|
||||
@@ -137,7 +137,7 @@ def delete_item(item_id: int, response: Response):
|
||||
----------
|
||||
item_id : int
|
||||
Identifier of the item to delete.
|
||||
response : fastapi.Response
|
||||
response : Response
|
||||
Response object used to set the HTTP status code.
|
||||
|
||||
Returns
|
||||
|
||||
@@ -24,15 +24,15 @@ Scaffolding via CLI
|
||||
Create a new OpenAPI-first health check service using the bundled
|
||||
template:
|
||||
|
||||
openapi-first health_app
|
||||
openapi-first scaffold health_app
|
||||
|
||||
Create the service in a custom directory:
|
||||
|
||||
openapi-first health_app my-health-service
|
||||
openapi-first scaffold health_app my-health-service
|
||||
|
||||
List all available application templates:
|
||||
|
||||
openapi-first --list
|
||||
openapi-first scaffold --list
|
||||
|
||||
The CLI copies template files verbatim into the target directory.
|
||||
No code is generated or modified beyond the copied scaffold.
|
||||
|
||||
@@ -13,7 +13,7 @@ This module serves solely as an operationId namespace.
|
||||
"""
|
||||
|
||||
|
||||
def get_health():
|
||||
def get_health() -> dict:
|
||||
"""
|
||||
Health check operation handler.
|
||||
|
||||
|
||||
@@ -28,15 +28,15 @@ Scaffolding via CLI
|
||||
|
||||
Create a new model-based CRUD example service using the bundled template:
|
||||
|
||||
openapi-first model_app
|
||||
openapi-first scaffold model_app
|
||||
|
||||
Create the service in a custom directory:
|
||||
|
||||
openapi-first model_app my-model-service
|
||||
openapi-first scaffold model_app my-model-service
|
||||
|
||||
List all available application templates:
|
||||
|
||||
openapi-first --list
|
||||
openapi-first scaffold --list
|
||||
|
||||
The CLI copies template files verbatim into the target directory.
|
||||
No code is generated or modified beyond the copied scaffold.
|
||||
|
||||
@@ -62,6 +62,8 @@ def get_item(item_id: int) -> Item:
|
||||
KeyError
|
||||
If the item does not exist.
|
||||
"""
|
||||
if item_id not in _items:
|
||||
raise KeyError(item_id)
|
||||
return _items[item_id]
|
||||
|
||||
|
||||
@@ -134,4 +136,6 @@ def delete_item(item_id: int) -> None:
|
||||
KeyError
|
||||
If the item does not exist.
|
||||
"""
|
||||
if item_id not in _items:
|
||||
raise KeyError(item_id)
|
||||
del _items[item_id]
|
||||
|
||||
@@ -26,7 +26,7 @@ from data import (
|
||||
)
|
||||
|
||||
|
||||
def list_items():
|
||||
def list_items() -> list[Item]:
|
||||
"""
|
||||
List all items.
|
||||
|
||||
@@ -41,7 +41,7 @@ def list_items():
|
||||
return _list_items()
|
||||
|
||||
|
||||
def get_item(item_id: int):
|
||||
def get_item(item_id: int) -> Item:
|
||||
"""
|
||||
Retrieve a single item by ID.
|
||||
|
||||
@@ -69,7 +69,7 @@ def get_item(item_id: int):
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
|
||||
def create_item(payload: ItemCreate, response: Response):
|
||||
def create_item(payload: ItemCreate, response: Response) -> Item:
|
||||
"""
|
||||
Create a new item.
|
||||
|
||||
@@ -80,7 +80,7 @@ def create_item(payload: ItemCreate, response: Response):
|
||||
----------
|
||||
payload : ItemCreate
|
||||
Request body describing the item to create.
|
||||
response : fastapi.Response
|
||||
response : Response
|
||||
Response object used to set the HTTP status code.
|
||||
|
||||
Returns
|
||||
@@ -93,7 +93,7 @@ def create_item(payload: ItemCreate, response: Response):
|
||||
return item
|
||||
|
||||
|
||||
def update_item(item_id: int, payload: ItemCreate):
|
||||
def update_item(item_id: int, payload: ItemCreate) -> Item:
|
||||
"""
|
||||
Update an existing item.
|
||||
|
||||
@@ -123,7 +123,7 @@ def update_item(item_id: int, payload: ItemCreate):
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
|
||||
def delete_item(item_id: int, response: Response):
|
||||
def delete_item(item_id: int, response: Response) -> None:
|
||||
"""
|
||||
Delete an existing item.
|
||||
|
||||
@@ -134,7 +134,7 @@ def delete_item(item_id: int, response: Response):
|
||||
----------
|
||||
item_id : int
|
||||
Identifier of the item to delete.
|
||||
response : fastapi.Response
|
||||
response : Response
|
||||
Response object used to set the HTTP status code.
|
||||
|
||||
Returns
|
||||
|
||||
@@ -50,11 +50,11 @@ Scaffolding via CLI
|
||||
|
||||
Create a new vet clinic service using the bundled template:
|
||||
|
||||
openapi-first vet_app
|
||||
openapi-first scaffold vet_app
|
||||
|
||||
Create the service in a custom directory:
|
||||
|
||||
openapi-first vet_app my-vet-clinic
|
||||
openapi-first scaffold vet_app my-vet-clinic
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Client Usage Example
|
||||
|
||||
@@ -43,7 +43,6 @@ from data import (
|
||||
create_pet as _create_pet,
|
||||
update_pet as _update_pet,
|
||||
delete_pet as _delete_pet,
|
||||
get_pet as _get_pet,
|
||||
list_appointments as _list_appointments,
|
||||
get_appointment as _get_appointment,
|
||||
create_appointment as _create_appointment,
|
||||
@@ -57,7 +56,7 @@ from data import (
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def list_parents(limit: int = 20, offset: int = 0):
|
||||
def list_parents(limit: int = 20, offset: int = 0) -> dict:
|
||||
"""List parents (paginated).
|
||||
|
||||
Parameters
|
||||
@@ -76,13 +75,15 @@ def list_parents(limit: int = 20, offset: int = 0):
|
||||
return {"total": len(items), "items": items[offset:offset + limit] if limit else items[offset:]}
|
||||
|
||||
|
||||
def create_parent(payload: ParentCreate, response: Response):
|
||||
def create_parent(payload: ParentCreate, response: Response) -> Parent:
|
||||
"""Create a parent.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
payload : ParentCreate
|
||||
Parent data excluding the ``id`` field.
|
||||
response : Response
|
||||
Response object used to set the HTTP status code.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -94,7 +95,7 @@ def create_parent(payload: ParentCreate, response: Response):
|
||||
return parent
|
||||
|
||||
|
||||
def get_parent(id: int):
|
||||
def get_parent(id: int) -> Parent:
|
||||
"""Retrieve a single parent by ID.
|
||||
|
||||
Parameters
|
||||
@@ -118,7 +119,7 @@ def get_parent(id: int):
|
||||
raise HTTPException(status_code=404, detail="Parent not found")
|
||||
|
||||
|
||||
def update_parent(id: int, payload: ParentCreate):
|
||||
def update_parent(id: int, payload: ParentCreate) -> Parent:
|
||||
"""Update an existing parent.
|
||||
|
||||
Parameters
|
||||
@@ -144,13 +145,15 @@ def update_parent(id: int, payload: ParentCreate):
|
||||
raise HTTPException(status_code=404, detail="Parent not found")
|
||||
|
||||
|
||||
def delete_parent(id: int, response: Response):
|
||||
def delete_parent(id: int, response: Response) -> None:
|
||||
"""Delete an existing parent.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
id : int
|
||||
Identifier of the parent.
|
||||
response : Response
|
||||
Response object used to set the HTTP status code.
|
||||
|
||||
Raises
|
||||
------
|
||||
@@ -212,7 +215,7 @@ def delete_vet(id: int, response: Response):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def list_treatments():
|
||||
def list_treatments() -> list[Treatment]:
|
||||
"""List treatments (catalogue).
|
||||
|
||||
Returns
|
||||
@@ -298,7 +301,7 @@ def delete_pet(id: int, response: Response):
|
||||
response.status_code = 204
|
||||
|
||||
|
||||
def upload_pet_photo(id: int, file: UploadFile):
|
||||
def upload_pet_photo(id: int, file: UploadFile) -> dict:
|
||||
"""Upload a pet photo.
|
||||
|
||||
Parameters
|
||||
|
||||
Reference in New Issue
Block a user