Compare commits
3 Commits
28c6d9e964
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c95de5a6e9 | |||
| b3f3068f8d | |||
| 7d075b3904 |
@@ -102,6 +102,7 @@ from . import client
|
||||
from . import errors
|
||||
from . import codegen
|
||||
from . import codegen_routes
|
||||
from . import security
|
||||
|
||||
__all__ = [
|
||||
"app",
|
||||
@@ -111,4 +112,5 @@ __all__ = [
|
||||
"errors",
|
||||
"codegen",
|
||||
"codegen_routes",
|
||||
"security",
|
||||
]
|
||||
|
||||
@@ -32,10 +32,34 @@ Notes:
|
||||
- Alter FastAPI dependency injection semantics.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .loader import load_openapi
|
||||
from .binder import bind_routes
|
||||
from .loader import load_openapi
|
||||
from .security import make_security_dependencies, parse_security_schemes
|
||||
|
||||
_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)
|
||||
|
||||
|
||||
def _resolve_spec_env_vars(obj):
|
||||
"""Recursively resolve {ENV_VAR} in all string values of the spec."""
|
||||
if isinstance(obj, str):
|
||||
return _resolve_env(obj)
|
||||
if isinstance(obj, dict):
|
||||
return {k: _resolve_spec_env_vars(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_resolve_spec_env_vars(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
class OpenAPIFirstApp(FastAPI):
|
||||
@@ -48,15 +72,19 @@ class OpenAPIFirstApp(FastAPI):
|
||||
- `OpenAPIFirstApp` subclasses `FastAPI` and replaces manual route
|
||||
registration with OpenAPI-driven binding.
|
||||
- All routes are derived from the provided OpenAPI specification,
|
||||
and each `operationId` is mapped to a Python function in the
|
||||
and each ``operationId`` is mapped to a Python function in the
|
||||
supplied routes module.
|
||||
- Auth dependencies are auto-injected from the spec's
|
||||
``securitySchemes`` and per-operation ``security`` fields.
|
||||
|
||||
**Guarantees:**
|
||||
|
||||
- No route can exist without an OpenAPI declaration.
|
||||
- No OpenAPI operation can exist without a handler.
|
||||
- Swagger UI and `/openapi.json` always reflect the provided spec.
|
||||
- Swagger UI and ``/openapi.json`` always reflect the provided spec.
|
||||
- Handler functions remain framework-agnostic and testable.
|
||||
- Auth enforcement is driven entirely by the spec — no manual
|
||||
middleware or decorators required.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@@ -87,28 +115,33 @@ class OpenAPIFirstApp(FastAPI):
|
||||
specification is treated as the authoritative API contract.
|
||||
routes_module (module):
|
||||
Python module containing handler functions whose names correspond
|
||||
exactly to OpenAPI `operationId` values.
|
||||
exactly to OpenAPI ``operationId`` values.
|
||||
**fastapi_kwargs (Any):
|
||||
Additional keyword arguments passed directly to
|
||||
`fastapi.FastAPI` (e.g., title, version, middleware, lifespan
|
||||
``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.
|
||||
``operationId`` does not have a corresponding handler function.
|
||||
"""
|
||||
# Initialize FastAPI normally
|
||||
super().__init__(**fastapi_kwargs)
|
||||
|
||||
# Load and validate OpenAPI specification
|
||||
self._openapi_spec = load_openapi(openapi_path)
|
||||
self._openapi_spec = _resolve_spec_env_vars(load_openapi(openapi_path))
|
||||
|
||||
# Bind routes strictly from OpenAPI spec
|
||||
# Parse security schemes and build per-route dependencies
|
||||
security_schemes = parse_security_schemes(self._openapi_spec)
|
||||
security_deps = make_security_dependencies(self._openapi_spec, security_schemes)
|
||||
|
||||
# Bind routes strictly from OpenAPI spec (with security deps)
|
||||
bind_routes(
|
||||
app=self,
|
||||
spec=self._openapi_spec,
|
||||
routes_module=routes_module,
|
||||
security_deps=security_deps,
|
||||
)
|
||||
|
||||
# Override FastAPI's OpenAPI generation
|
||||
|
||||
@@ -32,12 +32,19 @@ Notes:
|
||||
- Interpret OpenAPI semantics beyond routing metadata.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from .errors import MissingOperationHandler
|
||||
|
||||
|
||||
def bind_routes(app, spec: dict, routes_module) -> None:
|
||||
def bind_routes(
|
||||
app,
|
||||
spec: dict,
|
||||
routes_module,
|
||||
security_deps: dict[str, list[Any]] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Bind OpenAPI operations to FastAPI routes.
|
||||
|
||||
@@ -49,18 +56,24 @@ def bind_routes(app, spec: dict, routes_module) -> None:
|
||||
routes_module (module):
|
||||
Python module containing handler functions. Each handler's name MUST
|
||||
exactly match an OpenAPI `operationId`.
|
||||
security_deps (dict | None):
|
||||
Optional mapping of ``METHOD:/path`` → ``list[Depends(...)]``
|
||||
generated from the spec's ``securitySchemes`` and per-operation
|
||||
``security`` fields.
|
||||
|
||||
Raises:
|
||||
MissingOperationHandler:
|
||||
If an `operationId` is missing from the spec or if no corresponding
|
||||
If an ``operationId`` is missing from the spec or if no corresponding
|
||||
handler function exists in the routes module.
|
||||
|
||||
Notes:
|
||||
**Responsibilities:**
|
||||
|
||||
- Iterates through the OpenAPI specification paths and methods.
|
||||
- Resolves each `operationId` to a handler function, and registers
|
||||
a corresponding `APIRoute` on the FastAPI application.
|
||||
- Resolves each ``operationId`` to a handler function, and registers
|
||||
a corresponding ``APIRoute`` on the FastAPI application.
|
||||
- Injects FastAPI ``Depends()`` for each security requirement found
|
||||
on the operation or inherited from the top-level ``security`` field.
|
||||
|
||||
**Guarantees:**
|
||||
|
||||
@@ -70,6 +83,7 @@ def bind_routes(app, spec: dict, routes_module) -> None:
|
||||
"""
|
||||
|
||||
paths = spec.get("paths", {})
|
||||
security_deps = security_deps or {}
|
||||
|
||||
for path, methods in paths.items():
|
||||
for http_method, operation in methods.items():
|
||||
@@ -90,10 +104,14 @@ def bind_routes(app, spec: dict, routes_module) -> None:
|
||||
operation_id=operation_id,
|
||||
)
|
||||
|
||||
key = f"{http_method.upper()}:{path}"
|
||||
deps = security_deps.get(key, [])
|
||||
|
||||
route = APIRoute(
|
||||
path=path,
|
||||
endpoint=endpoint,
|
||||
methods=[http_method.upper()],
|
||||
dependencies=deps,
|
||||
name=operation_id,
|
||||
)
|
||||
|
||||
|
||||
145
openapi_first/security.py
Normal file
145
openapi_first/security.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
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).
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, Request, HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
_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), "")
|
||||
return _env_pattern.sub(_replace, value)
|
||||
|
||||
|
||||
def _resolve_scheme(scheme: dict) -> dict:
|
||||
"""Recursively resolve env vars in all string-valued fields of a scheme."""
|
||||
resolved = {}
|
||||
for key, value in scheme.items():
|
||||
if isinstance(value, str):
|
||||
resolved[key] = _resolve_env(value)
|
||||
elif isinstance(value, dict):
|
||||
resolved[key] = _resolve_scheme(value)
|
||||
else:
|
||||
resolved[key] = value
|
||||
return resolved
|
||||
|
||||
|
||||
def parse_security_schemes(spec: dict) -> dict[str, dict]:
|
||||
"""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:
|
||||
"""
|
||||
Create a FastAPI dependency that validates a Bearer JWT.
|
||||
|
||||
If *introspect_url* is provided the dependency calls that endpoint
|
||||
with ``{"token": "<token>"}`` and expects ``{"active": true, "user": …}``
|
||||
back. The resolved user dict is stored on ``request.state.user``.
|
||||
|
||||
Without an introspection URL the dependency only extracts the raw token
|
||||
and stores it on ``request.state.token`` — no remote validation.
|
||||
"""
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
async def _bearer_dep(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||
) -> None:
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
token = credentials.credentials
|
||||
|
||||
if introspect_url:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
resp = await client.post(
|
||||
introspect_url,
|
||||
json={"token": token},
|
||||
)
|
||||
except httpx.RequestError:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Authentication service unavailable",
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
|
||||
body = resp.json()
|
||||
if not body.get("active", False):
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
|
||||
request.state.user = body.get("user")
|
||||
else:
|
||||
request.state.token = token
|
||||
|
||||
return _bearer_dep
|
||||
|
||||
|
||||
def _build_dependency(scheme_name: str, scheme: dict) -> Callable | None:
|
||||
"""Return a FastAPI dependency callable for *scheme*, or ``None``."""
|
||||
scheme_type = scheme.get("type")
|
||||
|
||||
if scheme_type == "http" and scheme.get("scheme") == "bearer":
|
||||
server_url = scheme.get("x-server-url", "")
|
||||
introspect_path = scheme.get("x-introspect-path", "/introspect")
|
||||
introspect_url = server_url + introspect_path if server_url else None
|
||||
return _make_bearer_dependency(introspect_url)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def make_security_dependencies(
|
||||
spec: dict,
|
||||
security_schemes: dict[str, dict],
|
||||
) -> dict[str, list[Any]]:
|
||||
"""
|
||||
Build a mapping of ``METHOD:/path`` → list of ``Depends(...)``.
|
||||
|
||||
The effective security for each operation is resolved by:
|
||||
|
||||
1. Using the operation-level ``security`` field if present.
|
||||
2. Falling back to the top-level ``security`` field.
|
||||
3. An empty list means *no auth required* for that operation.
|
||||
"""
|
||||
global_security = spec.get("security", [])
|
||||
paths = spec.get("paths", {})
|
||||
|
||||
result: dict[str, list[Any]] = {}
|
||||
|
||||
for path, methods in paths.items():
|
||||
for http_method, operation in methods.items():
|
||||
if http_method.startswith("x-"):
|
||||
continue
|
||||
|
||||
op_security = operation.get("security", global_security)
|
||||
|
||||
depends_list: list[Any] = []
|
||||
for sec_req in op_security:
|
||||
for scheme_name in sec_req:
|
||||
scheme = security_schemes.get(scheme_name)
|
||||
if scheme:
|
||||
dep = _build_dependency(scheme_name, scheme)
|
||||
if dep:
|
||||
depends_list.append(Depends(dep))
|
||||
|
||||
key = f"{http_method.upper()}:{path}"
|
||||
result[key] = depends_list
|
||||
|
||||
return result
|
||||
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "openapi-first"
|
||||
version = "0.0.4"
|
||||
version = "0.0.6"
|
||||
description = "Strict OpenAPI-first application bootstrap for FastAPI."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -55,6 +55,9 @@ dependencies = [
|
||||
|
||||
# Code generation
|
||||
"datamodel-code-generator>=0.25.0",
|
||||
|
||||
# HTTP client for service-to-service (token introspection)
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
Reference in New Issue
Block a user