From b88ecbd7c5baf43ec89d3269dd35cedbf5532786 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sat, 10 Jan 2026 17:21:21 +0530 Subject: [PATCH] init openapi_first --- openapi_first/__init__.py | 141 ++++++++++++++++++++++++++++++++++++++ openapi_first/app.py | 118 +++++++++++++++++++++++++++++++ openapi_first/binder.py | 102 +++++++++++++++++++++++++++ openapi_first/errors.py | 72 +++++++++++++++++++ openapi_first/loader.py | 109 +++++++++++++++++++++++++++++ 5 files changed, 542 insertions(+) create mode 100644 openapi_first/__init__.py create mode 100644 openapi_first/app.py create mode 100644 openapi_first/binder.py create mode 100644 openapi_first/errors.py create mode 100644 openapi_first/loader.py diff --git a/openapi_first/__init__.py b/openapi_first/__init__.py new file mode 100644 index 0000000..814d64c --- /dev/null +++ b/openapi_first/__init__.py @@ -0,0 +1,141 @@ +""" +FastAPI OpenAPI First — strict OpenAPI-first application bootstrap for FastAPI. + +FastAPI OpenAPI First is a **contract-first infrastructure library** that +enforces OpenAPI as the single source of truth for FastAPI services. + +The library removes decorator-driven routing and replaces it with +deterministic, spec-driven application assembly. Every HTTP route, +method, and operation is defined in OpenAPI first and bound to Python +handlers explicitly via operationId. + +The package is intentionally minimal and layered. Each module has a +single responsibility and exposes explicit contracts rather than +convenience facades. + +---------------------------------------------------------------------- +Architecture Overview +---------------------------------------------------------------------- + +The library is structured around three core responsibilities: + +- loader: load and validate OpenAPI 3.x specifications (JSON/YAML) +- binder: bind OpenAPI operations to FastAPI routes via operationId +- app: OpenAPI-first FastAPI application bootstrap +- errors: explicit error hierarchy for contract violations + +The package root acts as a **namespace**, not a facade. Consumers are +expected to import functionality explicitly from the appropriate module. + +---------------------------------------------------------------------- +Installation +---------------------------------------------------------------------- + +Install using pip: + + pip install fastapi-openapi-first + +Or with Poetry: + + poetry add fastapi-openapi-first + +Runtime dependencies are intentionally minimal: +- fastapi +- openapi-spec-validator +- pyyaml (optional, for YAML specs) + +The ASGI server (e.g., uvicorn) is an application-level dependency and is +not bundled with this library. + +---------------------------------------------------------------------- +Basic Usage +---------------------------------------------------------------------- + +Minimal OpenAPI-first FastAPI application: + + from fastapi_openapi_first import app + import my_service.routes as routes + + api = app.OpenAPIFirstApp( + openapi_path="openapi.json", + routes_module=routes, + title="My Service", + version="1.0.0", + ) + + # Run with: + # uvicorn my_service.main:api + +Handler definitions (no decorators): + + def get_health(): + return {"status": "ok"} + +OpenAPI snippet: + + paths: + /health: + get: + operationId: get_health + responses: + "200": + description: OK + +---------------------------------------------------------------------- +Extensibility Model +---------------------------------------------------------------------- + +FastAPI OpenAPI First is designed to be extended via **explicit contracts**: + +- Users MAY extend OpenAPI loading behavior (e.g. multi-file specs) + by wrapping or replacing `loader.load_openapi` +- Users MAY extend route binding behavior by building on top of + `binder.bind_routes` +- Users MAY layer additional validation (e.g. signature checks) + without modifying core modules + +Users SHOULD NOT rely on FastAPI decorators for routing when using this +library. Mixing decorator-driven routes with OpenAPI-first routing +defeats the contract guarantees and is explicitly unsupported. + +---------------------------------------------------------------------- +Public API Surface +---------------------------------------------------------------------- + +The supported public API consists of the following top-level modules: + +- fastapi_openapi_first.app +- fastapi_openapi_first.binder +- fastapi_openapi_first.loader +- fastapi_openapi_first.errors + +Classes and functions should be imported explicitly from these modules. +No individual symbols are re-exported at the package root. + +---------------------------------------------------------------------- +Design Guarantees +---------------------------------------------------------------------- + +- OpenAPI is the single source of truth +- No undocumented routes can exist +- No OpenAPI operation can exist without a handler +- All contract violations fail at application startup +- No hidden FastAPI magic or implicit behavior +- Deterministic, testable application assembly +- CI-friendly failure modes + +FastAPI OpenAPI First favors correctness, explicitness, and contract +enforcement over convenience shortcuts. +""" + +from . import app +from . import binder +from . import loader +from . import errors + +__all__ = [ + "app", + "binder", + "loader", + "errors", +] diff --git a/openapi_first/app.py b/openapi_first/app.py new file mode 100644 index 0000000..b0744bd --- /dev/null +++ b/openapi_first/app.py @@ -0,0 +1,118 @@ +""" +fastapi_openapi_first.app +========================= + +OpenAPI-first application bootstrap for FastAPI. + +This module provides `OpenAPIFirstApp`, a thin but strict abstraction +that enforces OpenAPI as the single source of truth for a FastAPI service. + +Core principles +--------------- +- The OpenAPI specification (JSON or YAML) defines the entire API surface. +- Every operationId in the OpenAPI spec must have a corresponding + Python handler function. +- Handlers are plain Python callables (no FastAPI decorators). +- FastAPI route registration is derived exclusively from the spec. +- FastAPI's autogenerated OpenAPI schema is fully overridden. + +What this module does +--------------------- +- Loads and validates an OpenAPI 3.x specification. +- Dynamically binds HTTP routes to handler functions using operationId. +- Registers routes with FastAPI at application startup. +- Ensures runtime behavior matches the OpenAPI contract exactly. + +What this module does NOT do +---------------------------- +- It does not generate OpenAPI specs. +- It does not generate client code. +- It does not introduce a new framework or lifecycle. +- It does not alter FastAPI dependency injection semantics. + +Intended usage +-------------- +This module is intended for teams that want: + +- OpenAPI-first API development +- Strong contract enforcement +- Minimal FastAPI boilerplate +- Predictable, CI-friendly failures for spec/implementation drift +""" + +from fastapi import FastAPI + +from .loader import load_openapi +from .binder import bind_routes + + +class OpenAPIFirstApp(FastAPI): + """ + FastAPI application enforcing OpenAPI-first design. + + `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 supplied routes module. + + Parameters + ---------- + 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 + 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. + + Behavior 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. + - Handler functions remain framework-agnostic and testable. + + Example + ------- + >>> from fastapi_openapi_first import OpenAPIFirstApp + >>> import app.routes as routes + >>> + >>> app = OpenAPIFirstApp( + ... openapi_path="app/openapi.json", + ... routes_module=routes, + ... title="Example Service" + ... ) + """ + + def __init__( + self, + *, + openapi_path: str, + routes_module, + **fastapi_kwargs, + ): + # Initialize FastAPI normally + super().__init__(**fastapi_kwargs) + + # Load and validate OpenAPI specification + self._openapi_spec = load_openapi(openapi_path) + + # Bind routes strictly from OpenAPI spec + bind_routes( + app=self, + spec=self._openapi_spec, + routes_module=routes_module, + ) + + # Override FastAPI's OpenAPI generation + self.openapi = lambda: self._openapi_spec diff --git a/openapi_first/binder.py b/openapi_first/binder.py new file mode 100644 index 0000000..fb5fda1 --- /dev/null +++ b/openapi_first/binder.py @@ -0,0 +1,102 @@ +""" +fastapi_openapi_first.binder +============================ + +OpenAPI-driven route binding for FastAPI. + +This module is responsible for translating an OpenAPI 3.x specification +into concrete FastAPI routes. It enforces a strict one-to-one mapping +between OpenAPI operations and Python handler functions using operationId. + +Core responsibility +------------------- +- Read path + method definitions from an OpenAPI specification +- Resolve each operationId to a Python callable +- Register routes with FastAPI using APIRoute +- Fail fast when contract violations are detected + +Design constraints +------------------ +- All routes MUST be declared in the OpenAPI specification. +- All OpenAPI operations MUST define an operationId. +- Every operationId MUST resolve to a handler function. +- Handlers are plain Python callables (no decorators required). +- No implicit route creation or inference is allowed. + +This module intentionally does NOT: +------------------------------- +- Perform request or response validation +- Generate Pydantic models +- Modify FastAPI dependency injection +- Interpret OpenAPI semantics beyond routing metadata + +Those concerns belong to other layers or tooling. +""" + +from fastapi.routing import APIRoute + +from .errors import MissingOperationHandler + + +def bind_routes(app, spec: dict, routes_module) -> None: + """ + Bind OpenAPI operations to FastAPI routes. + + Iterates through the OpenAPI specification paths and methods, + resolves each operationId to a handler function, and registers + a corresponding APIRoute on the FastAPI application. + + Parameters + ---------- + app : fastapi.FastAPI + The FastAPI application instance to which routes will be added. + + spec : dict + Parsed OpenAPI 3.x specification dictionary. + + routes_module : module + Python module containing handler functions. Each handler's + name MUST exactly match an OpenAPI operationId. + + Raises + ------ + MissingOperationHandler + If an operationId is missing from the spec or if no corresponding + handler function exists in the routes module. + + Behavior guarantees + ------------------- + - Route registration is deterministic and spec-driven. + - No route decorators are required or supported. + - Handler resolution errors surface at application startup. + """ + + paths = spec.get("paths", {}) + + for path, methods in paths.items(): + for http_method, operation in methods.items(): + + operation_id = operation.get("operationId") + if not operation_id: + raise MissingOperationHandler( + path=path, + method=http_method, + ) + + try: + endpoint = getattr(routes_module, operation_id) + except AttributeError: + raise MissingOperationHandler( + path=path, + method=http_method, + operation_id=operation_id, + ) + + route = APIRoute( + path=path, + endpoint=endpoint, + methods=[http_method.upper()], + name=operation_id, + ) + + app.router.routes.append(route) diff --git a/openapi_first/errors.py b/openapi_first/errors.py new file mode 100644 index 0000000..4742183 --- /dev/null +++ b/openapi_first/errors.py @@ -0,0 +1,72 @@ +""" +fastapi_openapi_first.errors +============================ + +Custom exceptions for OpenAPI-first FastAPI applications. + +This module defines a small hierarchy of explicit, intention-revealing +exceptions used to signal contract violations between an OpenAPI +specification and its Python implementation. + +Design principles +----------------- +- Errors represent *programmer mistakes*, not runtime conditions. +- All errors are raised during application startup. +- Messages are actionable and suitable for CI/CD output. +- Exceptions are explicit rather than reused from generic built-ins. + +These errors should normally cause immediate application failure. +""" + +class OpenAPIFirstError(Exception): + """ + Base exception for all OpenAPI-first enforcement errors. + + This exception exists to allow callers, test suites, and CI pipelines + to catch and distinguish OpenAPI contract violations from unrelated + runtime errors. + + All exceptions raised by the OpenAPI-first core should inherit from + this type. + """ + pass + + +class MissingOperationHandler(OpenAPIFirstError): + """ + Raised when an OpenAPI operation cannot be resolved to a handler. + + This error occurs when: + - An OpenAPI operation does not define an operationId, or + - An operationId is defined but no matching function exists in the + provided routes module. + + This represents a violation of the OpenAPI-first contract and + indicates that the specification and implementation are out of sync. + """ + + def __init__(self, *, path: str, method: str, operation_id: str | None = None): + """ + Parameters + ---------- + 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})" + ) + + super().__init__(message) diff --git a/openapi_first/loader.py b/openapi_first/loader.py new file mode 100644 index 0000000..e0e2b51 --- /dev/null +++ b/openapi_first/loader.py @@ -0,0 +1,109 @@ +""" +fastapi_openapi_first.loaders +============================= + +OpenAPI specification loading and validation utilities. + +This module is responsible for loading an OpenAPI 3.x specification +from disk and validating it before it is used by the application. + +It enforces the principle that an invalid or malformed OpenAPI document +must never reach the routing or runtime layers. + +Design principles +----------------- +- OpenAPI is treated as an authoritative contract. +- Invalid specifications fail fast at application startup. +- Supported formats are JSON and YAML. +- Validation errors are surfaced clearly and early. + +This module intentionally does NOT: +----------------------------------- +- Modify the OpenAPI document +- Infer missing fields +- Generate models or code +- Perform request/response validation at runtime +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import yaml +from openapi_spec_validator import validate_spec + +from .errors import OpenAPIFirstError + + +class OpenAPISpecLoadError(OpenAPIFirstError): + """ + Raised when an OpenAPI specification cannot be loaded or validated. + + This error indicates that the OpenAPI document is unreadable, + malformed, or violates the OpenAPI 3.x specification. + """ + pass + + +def load_openapi(path: str | Path) -> dict[str, Any]: + """ + Load and validate an OpenAPI 3.x specification from disk. + + The specification is parsed based on file extension and validated + using a strict OpenAPI schema validator. Any error results in an + immediate exception, preventing application startup. + + Parameters + ---------- + path : str or pathlib.Path + Filesystem path to an OpenAPI specification file. + Supported extensions: + - `.json` + - `.yaml` + - `.yml` + + Returns + ------- + dict + Parsed and validated OpenAPI specification. + + Raises + ------ + OpenAPISpecLoadError + If the file does not exist, cannot be parsed, or fails + OpenAPI schema validation. + """ + spec_path = Path(path) + + if not spec_path.exists(): + raise OpenAPISpecLoadError(f"OpenAPI spec not found: {spec_path}") + + try: + if spec_path.suffix == ".json": + with spec_path.open("r", encoding="utf-8") as f: + spec = json.load(f) + + elif spec_path.suffix in {".yaml", ".yml"}: + with spec_path.open("r", encoding="utf-8") as f: + spec = yaml.safe_load(f) + + else: + raise OpenAPISpecLoadError( + f"Unsupported OpenAPI file format: {spec_path.suffix}" + ) + + except Exception as exc: + raise OpenAPISpecLoadError( + f"Failed to parse OpenAPI spec: {spec_path}" + ) from exc + + try: + validate_spec(spec) + except Exception as exc: + raise OpenAPISpecLoadError( + f"OpenAPI spec validation failed: {spec_path}" + ) from exc + + return spec