Compare commits
3 Commits
28c6d9e964
...
0.0.6
| Author | SHA1 | Date | |
|---|---|---|---|
| c95de5a6e9 | |||
| b3f3068f8d | |||
| 7d075b3904 |
@@ -102,6 +102,7 @@ from . import client
|
|||||||
from . import errors
|
from . import errors
|
||||||
from . import codegen
|
from . import codegen
|
||||||
from . import codegen_routes
|
from . import codegen_routes
|
||||||
|
from . import security
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"app",
|
"app",
|
||||||
@@ -111,4 +112,5 @@ __all__ = [
|
|||||||
"errors",
|
"errors",
|
||||||
"codegen",
|
"codegen",
|
||||||
"codegen_routes",
|
"codegen_routes",
|
||||||
|
"security",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -32,10 +32,34 @@ Notes:
|
|||||||
- Alter FastAPI dependency injection semantics.
|
- Alter FastAPI dependency injection semantics.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from .loader import load_openapi
|
|
||||||
from .binder import bind_routes
|
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):
|
class OpenAPIFirstApp(FastAPI):
|
||||||
@@ -48,15 +72,19 @@ class OpenAPIFirstApp(FastAPI):
|
|||||||
- `OpenAPIFirstApp` subclasses `FastAPI` and replaces manual route
|
- `OpenAPIFirstApp` subclasses `FastAPI` and replaces manual route
|
||||||
registration with OpenAPI-driven binding.
|
registration with OpenAPI-driven binding.
|
||||||
- All routes are derived from the provided OpenAPI specification,
|
- 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.
|
supplied routes module.
|
||||||
|
- Auth dependencies are auto-injected from the spec's
|
||||||
|
``securitySchemes`` and per-operation ``security`` fields.
|
||||||
|
|
||||||
**Guarantees:**
|
**Guarantees:**
|
||||||
|
|
||||||
- No route can exist without an OpenAPI declaration.
|
- No route can exist without an OpenAPI declaration.
|
||||||
- No OpenAPI operation can exist without a handler.
|
- 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.
|
- Handler functions remain framework-agnostic and testable.
|
||||||
|
- Auth enforcement is driven entirely by the spec — no manual
|
||||||
|
middleware or decorators required.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
@@ -87,28 +115,33 @@ class OpenAPIFirstApp(FastAPI):
|
|||||||
specification is treated as the authoritative API contract.
|
specification is treated as the authoritative API contract.
|
||||||
routes_module (module):
|
routes_module (module):
|
||||||
Python module containing handler functions whose names correspond
|
Python module containing handler functions whose names correspond
|
||||||
exactly to OpenAPI `operationId` values.
|
exactly to OpenAPI ``operationId`` values.
|
||||||
**fastapi_kwargs (Any):
|
**fastapi_kwargs (Any):
|
||||||
Additional keyword arguments passed directly to
|
Additional keyword arguments passed directly to
|
||||||
`fastapi.FastAPI` (e.g., title, version, middleware, lifespan
|
``fastapi.FastAPI`` (e.g., title, version, middleware, lifespan
|
||||||
handlers).
|
handlers).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
OpenAPIFirstError:
|
OpenAPIFirstError:
|
||||||
If the OpenAPI specification is invalid, or if any declared
|
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
|
# Initialize FastAPI normally
|
||||||
super().__init__(**fastapi_kwargs)
|
super().__init__(**fastapi_kwargs)
|
||||||
|
|
||||||
# Load and validate OpenAPI specification
|
# 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(
|
bind_routes(
|
||||||
app=self,
|
app=self,
|
||||||
spec=self._openapi_spec,
|
spec=self._openapi_spec,
|
||||||
routes_module=routes_module,
|
routes_module=routes_module,
|
||||||
|
security_deps=security_deps,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Override FastAPI's OpenAPI generation
|
# Override FastAPI's OpenAPI generation
|
||||||
|
|||||||
@@ -32,12 +32,19 @@ Notes:
|
|||||||
- Interpret OpenAPI semantics beyond routing metadata.
|
- Interpret OpenAPI semantics beyond routing metadata.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi.routing import APIRoute
|
from fastapi.routing import APIRoute
|
||||||
|
|
||||||
from .errors import MissingOperationHandler
|
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.
|
Bind OpenAPI operations to FastAPI routes.
|
||||||
|
|
||||||
@@ -49,18 +56,24 @@ def bind_routes(app, spec: dict, routes_module) -> None:
|
|||||||
routes_module (module):
|
routes_module (module):
|
||||||
Python module containing handler functions. Each handler's name MUST
|
Python module containing handler functions. Each handler's name MUST
|
||||||
exactly match an OpenAPI `operationId`.
|
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:
|
Raises:
|
||||||
MissingOperationHandler:
|
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.
|
handler function exists in the routes module.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
**Responsibilities:**
|
**Responsibilities:**
|
||||||
|
|
||||||
- Iterates through the OpenAPI specification paths and methods.
|
- Iterates through the OpenAPI specification paths and methods.
|
||||||
- Resolves each `operationId` to a handler function, and registers
|
- Resolves each ``operationId`` to a handler function, and registers
|
||||||
a corresponding `APIRoute` on the FastAPI application.
|
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:**
|
**Guarantees:**
|
||||||
|
|
||||||
@@ -70,6 +83,7 @@ def bind_routes(app, spec: dict, routes_module) -> None:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
paths = spec.get("paths", {})
|
paths = spec.get("paths", {})
|
||||||
|
security_deps = security_deps or {}
|
||||||
|
|
||||||
for path, methods in paths.items():
|
for path, methods in paths.items():
|
||||||
for http_method, operation in methods.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,
|
operation_id=operation_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
key = f"{http_method.upper()}:{path}"
|
||||||
|
deps = security_deps.get(key, [])
|
||||||
|
|
||||||
route = APIRoute(
|
route = APIRoute(
|
||||||
path=path,
|
path=path,
|
||||||
endpoint=endpoint,
|
endpoint=endpoint,
|
||||||
methods=[http_method.upper()],
|
methods=[http_method.upper()],
|
||||||
|
dependencies=deps,
|
||||||
name=operation_id,
|
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]
|
[project]
|
||||||
name = "openapi-first"
|
name = "openapi-first"
|
||||||
version = "0.0.4"
|
version = "0.0.6"
|
||||||
description = "Strict OpenAPI-first application bootstrap for FastAPI."
|
description = "Strict OpenAPI-first application bootstrap for FastAPI."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
@@ -55,6 +55,9 @@ dependencies = [
|
|||||||
|
|
||||||
# Code generation
|
# Code generation
|
||||||
"datamodel-code-generator>=0.25.0",
|
"datamodel-code-generator>=0.25.0",
|
||||||
|
|
||||||
|
# HTTP client for service-to-service (token introspection)
|
||||||
|
"httpx>=0.27.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
Reference in New Issue
Block a user