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