diff --git a/openapi_first/__init__.py b/openapi_first/__init__.py index ea42349..a82a1fc 100644 --- a/openapi_first/__init__.py +++ b/openapi_first/__init__.py @@ -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", ] diff --git a/openapi_first/app.py b/openapi_first/app.py index 73ec8d3..1ac6233 100644 --- a/openapi_first/app.py +++ b/openapi_first/app.py @@ -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 diff --git a/openapi_first/binder.py b/openapi_first/binder.py index e69dcc3..b512c0e 100644 --- a/openapi_first/binder.py +++ b/openapi_first/binder.py @@ -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, ) diff --git a/openapi_first/security.py b/openapi_first/security.py new file mode 100644 index 0000000..0db8f6a --- /dev/null +++ b/openapi_first/security.py @@ -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": ""}`` 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 diff --git a/pyproject.toml b/pyproject.toml index 1f69e7c..04f0405 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]