init openapi_first
This commit is contained in:
141
openapi_first/__init__.py
Normal file
141
openapi_first/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
118
openapi_first/app.py
Normal file
118
openapi_first/app.py
Normal file
@@ -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
|
||||||
102
openapi_first/binder.py
Normal file
102
openapi_first/binder.py
Normal file
@@ -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)
|
||||||
72
openapi_first/errors.py
Normal file
72
openapi_first/errors.py
Normal file
@@ -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)
|
||||||
109
openapi_first/loader.py
Normal file
109
openapi_first/loader.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user