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:
2026-10-17 02:16:49 +05:30
parent c95de5a6e9
commit 621f10409e
162 changed files with 10165 additions and 2846 deletions

View File

@@ -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",
]

View File

@@ -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)

View File

@@ -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: ...

View File

@@ -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, [])

View File

@@ -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: ...

View File

@@ -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
View 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: ...

View File

@@ -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("/"))

View File

@@ -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]]: ...

View File

@@ -9,6 +9,7 @@ definitions.
"""
from pathlib import Path
from datamodel_code_generator import (
InputFileType,
PythonVersion,

11
openapi_first/codegen.pyi Normal file
View 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"]

View File

@@ -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

View 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]: ...

View File

@@ -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)

View File

@@ -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: ...

View File

@@ -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

View File

@@ -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
View File

View 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(...)``.

View 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]]: ...

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -13,7 +13,7 @@ This module serves solely as an operationId namespace.
"""
def get_health():
def get_health() -> dict:
"""
Health check operation handler.

View File

@@ -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.

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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