diff --git a/CHANGELOG.md b/CHANGELOG.md
index f84da141..31485b7d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,45 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
---
+## v26.09.12 (2026-09-30)
+
+### Added
+
+- Documentation-only `@openapi_operation` and public operation, request body,
+ parameter, response and response-header metadata for manual handlers. Declare
+ optional bodies, multiple media types/statuses and exact response headers
+ without changing function signatures, request binding or response dispatch.
+- Public framework-neutral `RouteMetadata` supports offline generation without
+ application startup, service resolution or optional web dependencies.
+- OpenAPI security schemes, global requirements and per-operation overrides,
+ including explicit public operations. These declarations describe the contract;
+ they neither grant access nor enforce authentication or authorization.
+- Public custom Pydantic `schema_generator` and `create_app(openapi_generator=...)`
+ extension points for consumer schema policies and the served specification.
+
+### Fixed
+
+- Generate unions, discriminated unions, `Annotated` types, containers, recursive
+ models, UUIDs, enums and parameter constraints using Pydantic. Input schemas use
+ validation mode; response schemas use serialization mode, preserving aliases.
+- Resolve schema references and discriminator mappings across colliding model
+ names and differing input/output schemas. Repeated exports and reversed route
+ discovery produce deterministic documents.
+- Respect explicit operation IDs and mapping names, qualify inferred name
+ collisions, and reject duplicate explicit IDs or controller path/method pairs.
+- Inspect controller methods without evaluating custom descriptors or properties.
+
+### Upgrading
+
+- Existing decorators and runtime bindings retain their behavior. Explicit
+ responses replace the declared status, including framework validation errors;
+ `replace_responses=True` replaces the whole inferred response map. An explicit
+ `request_body=None` suppresses body inference and `security=[]` overrides global
+ requirements for that operation.
+- Richer inferred schemas and collision-qualified IDs intentionally change the
+ generated specification. Set an explicit unique name/operation ID for clients
+ that require a fixed identifier. See the web guide for complete examples.
+
## v26.09.11 (2026-09-30)
### Fixed
diff --git a/README.md b/README.md
index 5c9e2b60..a0ee863b 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-
+
@@ -850,13 +850,13 @@ See **[`samples/lumen/`](samples/lumen/README.md)** for an end-to-end DDD micros
```bash
# Install the latest release (uv)
-uv add "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.11-py3-none-any.whl"
+uv add "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.12-py3-none-any.whl"
# Install with specific extras
-uv add "pyfly[web,data-relational,cache] @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.11-py3-none-any.whl"
+uv add "pyfly[web,data-relational,cache] @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.12-py3-none-any.whl"
# Or with pip
-pip install "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.11-py3-none-any.whl"
+pip install "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.12-py3-none-any.whl"
```
### One-Line Install (CLI + Framework)
@@ -1184,6 +1184,7 @@ The git tag and human-readable display use the leading-zero form (`v26.05.01`);
The full release history lives in **[CHANGELOG.md](CHANGELOG.md)** ([Keep a Changelog](https://keepachangelog.com/) format). Recent highlights:
+- **`v26.09.12`** (2026-09-30) — explicit offline OpenAPI contracts, rich Pydantic schemas, response/security overrides and stable operation IDs without changing handler behavior.
- **`v26.09.11`** (2026-09-30) — scheduled-method discovery skips custom descriptors, avoiding Pydantic instance-field deprecation warnings.
- **`v26.09.10`** (2026-09-29) — bounded JWKS rotation and HTTP responses, standalone OAuth acquisition, complete failed-start cleanup, substitutable observability and exact-byte webhook validation.
- **`v26.09.09`** (2026-09-28) — a restarted context no longer resolves an interface through the previous run's binding (a second `start()` of the same context failed with a `KeyError`).
diff --git a/docs/cli.md b/docs/cli.md
index 5a97ed03..1b05f486 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -775,7 +775,7 @@ Missing optional tools are shown with a `-` dash indicator (dimmed), while missi
Verifies that PyFly itself is importable and displays the installed version:
```
-✓ pyfly v26.09.11
+✓ pyfly v26.09.12
```
### Summary
diff --git a/docs/getting-started.md b/docs/getting-started.md
index c0ecb2ef..3fc2f31f 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -480,7 +480,7 @@ ______ ___.__._/ ____\ | ___.__.
| __// ____| |__| |____/ ____|
|__| \/ \/
- PyFly v26.09.11 | Python 3.12.0
+ PyFly v26.09.12 | Python 3.12.0
2026-01-15T10:30:00Z [info] starting_application app=my-service version=0.1.0
2026-01-15T10:30:00Z [info] no_active_profiles message=No active profiles set, falling back to default
diff --git a/docs/installation.md b/docs/installation.md
index f869d79a..3311ff87 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -513,7 +513,7 @@ PyFly Doctor
✓ mypy — Type checker
PyFly packages:
- ✓ pyfly v26.09.11
+ ✓ pyfly v26.09.12
All checks passed!
```
diff --git a/docs/modules/core.md b/docs/modules/core.md
index 79bc4404..c34f3f82 100644
--- a/docs/modules/core.md
+++ b/docs/modules/core.md
@@ -599,7 +599,7 @@ class BannerMode(enum.Enum):
| Mode | Behavior |
|---|---|
| `TEXT` | Full ASCII art banner (default) with a framework version line. |
-| `MINIMAL` | Single line: `:: PyFly :: (v26.09.11)` |
+| `MINIMAL` | Single line: `:: PyFly :: (v26.09.12)` |
| `OFF` | No banner output at all. |
### BannerPrinter Class
@@ -640,7 +640,7 @@ ______ ___.__._/ ____\ | ___.__.
| __// ____| |__| |____/ ____|
|__| \/ \/
-:: PyFly Framework :: (v26.09.11)
+:: PyFly Framework :: (v26.09.12)
```
### Custom Banner Files
diff --git a/docs/modules/web.md b/docs/modules/web.md
index f89cf7d4..12802446 100644
--- a/docs/modules/web.md
+++ b/docs/modules/web.md
@@ -78,6 +78,8 @@ The PyFly web layer provides enterprise-grade HTTP routing, controller registrat
- [Swagger UI](#swagger-ui)
- [ReDoc](#redoc)
- [OpenAPIGenerator Internals](#openapigenerator-internals)
+ - [Explicit contracts for manual handlers](#explicit-contracts-for-manual-handlers)
+ - [Offline generation and customization](#offline-generation-and-customization)
- [Application Factory: create_app()](#application-factory-create_app)
- [Full Parameter Reference](#full-parameter-reference)
- [What create_app() Does](#what-create_app-does)
@@ -1888,29 +1890,192 @@ The raw OpenAPI 3.1 specification is served at `/openapi.json`.
### OpenAPIGenerator Internals
-The `OpenAPIGenerator` class (`src/pyfly/web/openapi.py`) builds the spec:
+`OpenAPIGenerator` generates OpenAPI 3.1 from framework-neutral `RouteMetadata`.
+Collection reads controller classes without resolving beans or evaluating arbitrary
+property/custom descriptors. Ordinary methods, inherited methods, static methods,
+and class methods are discoverable. Collection and generation do not start the
+application, invoke handlers, or contact services.
+
+- `PathVar[T]`, `QueryParam[T]`, `Header[T]`, and `Cookie[T]` become parameters.
+ Header names replace underscores with hyphens. Path parameters are required;
+ other inferred parameters are optional when they have a default or admit `None`.
+ Defaults are JSON serialized, including enums and UUIDs.
+- `Body[T]` and `Valid[T]` become JSON request bodies. Binding sentinels are removed
+ while other `Annotated` constraints are preserved. Inferred bodies remain required:
+ the existing body binder always reads the body and does not use Python parameter
+ defaults to make it optional. A nullable schema describes JSON `null`, independently
+ of body presence; inference of union schemas does not add union binding support.
+ For an optional manually parsed body, set `OpenAPIRequestBody(required=False, ...)`.
+- Serializable return types become response schemas: models, primitives, UUIDs,
+ enums, literals, unions, collections, recursive models, and discriminated unions.
+ Unsupported inferred runtime types such as `Request` and `JSONResponse` retain
+ a generic response description without invented response content. A 204 response
+ remains bodyless, and SSE retains its `text/event-stream` content.
+- Request bodies and parameters use Pydantic **validation** mode. Response content
+ and headers use **serialization** mode. Both preserve aliases. When input/output
+ shapes differ, Pydantic emits separate definitions. Shared definitions preserve
+ recursive references and discriminator targets without same-name model collisions.
+- A documented request body adds the legacy inferred 422 validation response unless
+ that status is overridden or responses are completely replaced. This default is
+ documentation; applications with a different error envelope should override it.
+
+The operation ID uses the explicit documentation ID, then the mapping's `name`,
+then the handler name. Unique legacy names remain unchanged. Implicit collisions
+are assigned deterministically in sorted path/method order, controllers before
+mounted routes; later holders get `__` and, if needed, a
+numeric suffix. All preferred names are reserved before suffix assignment. Duplicate
+explicit IDs and duplicate controller path/method declarations raise `ValueError`.
+
+Routes and sub-applications supplied through `create_app(extra_routes=...)` remain
+marked `x-pyfly-mounted: true`, with their names, summaries, path parameters and a
+`default` response. A controller takes precedence on an overlapping path/method.
+Opaque mounts are listed in `x-pyfly-mounts`; WebSocket inventory is published in
+`x-pyfly-websocket-routes` because WebSockets have no OpenAPI operation representation.
+
+### Explicit contracts for manual handlers
+
+`@openapi_operation` attaches documentation to the original function. It does not
+wrap the handler, change annotations/signatures, perform validation, acquire services,
+or modify response bytes/status/headers. It works above or below an HTTP mapping.
+Use it when a handler reads a raw `Request` or returns a manually constructed response:
-1. **Info** -- populated from `title`, `version`, and `description` passed to `create_app()`.
-2. **Tags** -- derived from controller class names (`OrderController` becomes the `Order` tag).
-3. **Paths** -- built from `RouteMetadata` collected by `ControllerRegistrar.collect_route_metadata()`. Each handler contributes an operation with `operationId`, parameters, request body, responses, tags, summary, description, and deprecated flag.
-4. **Components/Schemas** -- Pydantic models used as `Body[T]` or `Valid[T]` types are registered in `components.schemas` via `model_json_schema()`, and referenced using `$ref`. Nested `$defs` from Pydantic v2 are automatically hoisted into `components/schemas` and `$ref` paths are rewritten accordingly.
-5. **Validation Error Schemas** -- Endpoints with request bodies automatically include a `422 Validation Error` response with the standard `HTTPValidationError` and `ValidationError` schemas.
-6. **Mounted routes** -- the plain `Route` objects and `Mount`ed sub-applications handed to `create_app(extra_routes=...)` are walked (recursively, with the mount prefix) and emitted as operations marked `x-pyfly-mounted: true`. There is no handler signature to read a contract from, so each carries its `operationId`, the endpoint's docstring summary, a `default` response and the path parameters Starlette's convertors declare (`{botId}` → string, `{n:int}` → integer, `{x:float}` → number, `{id:uuid}` → string/uuid; a mount's own parameters are inherited). `operationId`s are unique across the document: the first holder of a name keeps it and a later endpoint with the same `__name__` is qualified as `__`. A mount whose app cannot be walked is listed under `x-pyfly-mounts`. A controller's operation on the same path and method is never overwritten.
+```python
+from uuid import UUID, uuid4
+
+from pydantic import BaseModel, ValidationError
+from starlette.requests import Request
+from starlette.responses import JSONResponse, PlainTextResponse
+
+from pyfly.container.stereotypes import rest_controller
+from pyfly.web import (
+ OpenAPIHeader, OpenAPIParameter, OpenAPIRequestBody, OpenAPIResponse,
+ openapi_operation, post_mapping,
+)
+
+class Submission(BaseModel):
+ message: str
+
+class Receipt(BaseModel):
+ job_id: UUID
+
+@rest_controller
+class JobsController:
+ @post_mapping("/jobs", name="jobs.submit")
+ @openapi_operation(
+ summary="Accept a job",
+ parameters=[OpenAPIParameter("X-Tenant", "header", UUID)],
+ request_body=OpenAPIRequestBody(
+ content={"application/json": Submission}, required=True,
+ description="The job to accept",
+ ),
+ responses={
+ 202: OpenAPIResponse(
+ "Accepted", content={"application/json": Receipt},
+ headers={"X-Job-ID": OpenAPIHeader(UUID, description="Tracking ID")},
+ ),
+ 422: OpenAPIResponse("Invalid submission", content={"text/plain": str}),
+ },
+ replace_responses=True,
+ security=[],
+ )
+ async def submit(self, request: Request) -> JSONResponse | PlainTextResponse:
+ # The handler owns validation and the actual response contract.
+ try:
+ UUID(request.headers["X-Tenant"])
+ Submission.model_validate(await request.json())
+ except (ValueError, KeyError, ValidationError):
+ return PlainTextResponse("Invalid submission", status_code=422)
+ job_id = uuid4()
+ return JSONResponse(
+ {"job_id": str(job_id)}, status_code=202,
+ headers={"X-Job-ID": str(job_id)},
+ )
+```
+
+Typed schema inputs are
+Python types, annotated aliases, or existing Pydantic `TypeAdapter` instances, not raw
+JSON-schema dictionaries. Invalid explicit schema inputs fail with the responsible
+HTTP method/path instead of silently disappearing.
+
+| Setting | Omitted/default | Explicit override |
+|---------|-----------------|-------------------|
+| `operation_id` | Mapping name, then handler name | Nonempty unique ID |
+| `request_body` | Infer from bindings | `OpenAPIRequestBody` replaces it; `None` omits it |
+| `parameters` | Infer from bindings | Complete replacement; `[]` removes inferred parameters |
+| `responses` | Infer success and applicable 422 | Merge by status, replacing each declared status completely |
+| `replace_responses` | `False` | `True` requires a nonempty response map and removes all inferred statuses |
+| `summary`, `description` | Infer from docstrings | Empty strings suppress inferred text |
+| `tags` | Infer from controller name | Complete replacement; `[]` removes tags |
+| `deprecated` | Infer existing deprecated marker | `False` clears it |
+| `security` | Inherit document-level requirements | `[]` explicitly declares no security requirement |
+
+Response keys accept integer/string status codes (100–599), `"default"`, and ranges
+such as `"2XX"`. Response `content` and request-body `content` map media types to
+schema inputs. Empty response content documents a bodyless response; request-body
+content must be nonempty. `OpenAPIParameter(name, location, schema, required=True,
+description="", default=...)` accepts `path`, `query`, `header`, or `cookie` locations.
+Path parameters must be required. An omitted `default` differs from an explicit
+`default=None`. `OpenAPIHeader(schema, description="", required=False)` describes a
+response header. None of these declarations expands the runtime parameter binder's
+supported types; handlers remain responsible for manual contracts and coercion.
+
+### Offline generation and customization
+
+`RouteMetadata` and the documentation value types are available from `pyfly.web`
+without installing Starlette. The existing adapter import of `RouteMetadata` remains
+compatible. An offline caller can supply metadata directly without a context or handler:
-Parameters are extracted from handler type hints (with `Valid` wrapper automatically peeled):
-- `PathVar[T]` becomes an `in: path` parameter
-- `QueryParam[T]` becomes an `in: query` parameter
-- `Header[T]` becomes an `in: header` parameter
-- `Cookie[T]` becomes an `in: cookie` parameter
-- `Body[BaseModel]` or `Valid[BaseModel]` becomes a `requestBody` with a JSON schema reference
+```python
+from typing import Annotated
+from pydantic import Field, TypeAdapter
+from pydantic.json_schema import GenerateJsonSchema
+
+from pyfly.web import OpenAPIOperation, OpenAPIResponse, RouteMetadata
+from pyfly.web.openapi import OpenAPIGenerator
+
+class ContractSchema(GenerateJsonSchema):
+ def generate_inner(self, schema):
+ result = super().generate_inner(schema)
+ if result.get("type") == "integer":
+ result["x-contract-integer"] = True
+ return result
+
+metadata = RouteMetadata(
+ path="/count", http_method="GET", status_code=200,
+ handler=None, handler_name="count",
+ operation=OpenAPIOperation(
+ operation_id="metrics.count",
+ responses={200: OpenAPIResponse(
+ "Current count",
+ content={"application/json": TypeAdapter(Annotated[int, Field(ge=0)])},
+ )},
+ ),
+)
+generator = OpenAPIGenerator(
+ title="Metrics", version="1.0", schema_generator=ContractSchema,
+ security_schemes={"bearer": {"type": "http", "scheme": "bearer"}},
+ security=[{"bearer": []}],
+)
+spec = generator.generate([metadata])
+```
+
+Security schemes and global/per-operation requirements describe documentation only;
+**they never enforce authentication or authorization**. Configure runtime security
+filters separately. To use the same customization at the actual documentation endpoint:
+
+```python
+from pyfly.web.adapters.starlette import create_app
+
+app = create_app(context=context, openapi_generator=generator)
+```
-Response schemas are derived from handler return types:
-- `BaseModel` subclass returns produce a `$ref` to the model's schema
-- `list[BaseModel]` returns produce an `array` schema with `items.$ref`
-- `None` returns (204) produce a `"No Content"` description
-- Other returns produce a generic `"Successful response"` description
+The supplied generator controls the `/openapi.json` document's info, schemes and schema
+policy. `create_app`'s `title` still controls documentation UI titles. The custom
+`GenerateJsonSchema` subclass is used for every schema input and shared definition;
+this supports consumer-specific Pydantic hooks without monkey-patching the generator.
-Source file: `src/pyfly/web/openapi.py`
+Source files: `src/pyfly/web/openapi.py`, `src/pyfly/web/openapi_metadata.py`,
+`src/pyfly/web/openapi_schema.py`.
---
@@ -2000,6 +2165,7 @@ app = create_app(
| `debug` | `bool` | `False` | Starlette debug mode |
| `context` | `ApplicationContext \| None` | `None` | DI context for auto-discovering `@rest_controller` beans |
| `docs_enabled` | `bool` | `True` | Mount OpenAPI spec, Swagger UI, and ReDoc |
+| `openapi_generator` | `OpenAPIGenerator \| None` | `None` | Supply the generator for `/openapi.json` |
| `extra_routes` | `list[Route] \| None` | `None` | Additional Starlette routes to mount |
| `actuator_enabled` | `bool` | `False` | Mount actuator health/info endpoints |
| `cors` | `CORSConfig \| None` | `None` | CORS configuration. When `None` and a `context` is given, it is auto-built from `pyfly.web.cors.*` (disabled unless `pyfly.web.cors.enabled` is true) |
@@ -2044,16 +2210,17 @@ The `ControllerRegistrar` (`src/pyfly/web/adapters/starlette/controller.py`) is
`collect_route_metadata(context)` performs the same discovery but returns `RouteMetadata` objects instead of `Route` objects. Each `RouteMetadata` contains:
- `path`, `http_method`, `status_code`
-- `handler`, `handler_name`
-- `parameters` -- list of OpenAPI parameter dicts extracted from type hints
-- `request_body_model` -- the Pydantic model class if `Body[BaseModel]` or `Valid[BaseModel]` is used
+- `handler`, `handler_name`, `mapping_name`
+- `operation` -- optional `OpenAPIOperation` overrides, also attached by `@openapi_operation`
+- `parameters` -- compatible parameter dictionaries; `parameter_types` retains rich Python schema inputs by `(location, name)`
+- `request_body_model` -- the Python schema input from `Body[T]` or `Valid[T]`, including non-binding `Annotated` metadata
- `return_type` -- the handler's return type annotation
- `tag` -- derived from the controller class name (e.g., `OrderController` becomes `Order`)
- `summary` -- first line of the handler's docstring
- `description` -- remaining lines of the handler's docstring (after a blank separator)
- `deprecated` -- `True` if the handler is marked with `__pyfly_deprecated__`
-This metadata is consumed by `OpenAPIGenerator` to build the spec. The `Valid` wrapper is automatically peeled during metadata extraction, so `Valid[Body[CreateOrderRequest]]` produces the same OpenAPI schema as `Body[CreateOrderRequest]`.
+This metadata is consumed by `OpenAPIGenerator` to build the spec. Binding markers are removed while Pydantic constraints remain intact, so `Valid[Body[CreateOrderRequest]]` produces the same OpenAPI schema as `Body[CreateOrderRequest]`.
### Exception Handler Discovery
diff --git a/docs/versioning.md b/docs/versioning.md
index 8fd6deb0..7a53b2b8 100644
--- a/docs/versioning.md
+++ b/docs/versioning.md
@@ -64,6 +64,7 @@ rare case where a substantial change needs an additional review window.
| Version | Date | Notes |
|---------|------|-------|
+| `26.09.12` | 2026-09-30 | Offline OpenAPI contracts, rich schema inference and explicit operation overrides. |
| `26.09.11` | 2026-09-30 | Scheduled-method discovery avoids evaluating Pydantic and custom descriptors. |
| `26.09.10` | 2026-09-29 | Bounded identity and HTTP primitives, lifecycle ownership, substitutable observability and exact-byte webhooks. |
| `26.09.09` | 2026-09-28 | A restarted context drops the previous run's interface bindings with its registrations, so a second `start()` of the same context no longer fails with a `KeyError` (a test suite whose modules share an application). |
@@ -94,17 +95,17 @@ shipped, with the version metadata updated.
```python
import pyfly
-print(pyfly.__version__) # → "26.09.11"
+print(pyfly.__version__) # → "26.09.12"
```
```bash
-pyfly --version # → 26.09.11
+pyfly --version # → 26.09.12
```
The startup banner displays the leading-zero form:
```
-:: PyFly Framework :: (v26.09.11) (Python 3.13.9)
+:: PyFly Framework :: (v26.09.12) (Python 3.13.9)
```
---
diff --git a/install.sh b/install.sh
index 56e55773..01dec7a3 100755
--- a/install.sh
+++ b/install.sh
@@ -26,7 +26,7 @@ set -euo pipefail
# ── Constants ──────────────────────────────────────────────────────────────────
-PYFLY_VERSION="26.09.11"
+PYFLY_VERSION="26.09.12"
PYFLY_REPO="https://github.com/fireflyframework/fireflyframework-pyfly.git"
DEFAULT_INSTALL_DIR="$HOME/.pyfly"
MIN_PYTHON_MAJOR=3
diff --git a/pyproject.toml b/pyproject.toml
index e695f282..bc6ed6f0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ name = "pyfly"
# CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4);
# git tag, GitHub release and human-readable display use leading-zero form
# (v26.05.04) to match the Java/.NET/Go siblings.
-version = "26.9.11"
+version = "26.9.12"
description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more."
readme = "README.md"
license = "Apache-2.0"
diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py
index bd6267d9..30df493a 100644
--- a/src/pyfly/__init__.py
+++ b/src/pyfly/__init__.py
@@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""
-__version__ = "26.09.11"
+__version__ = "26.09.12"
diff --git a/src/pyfly/web/__init__.py b/src/pyfly/web/__init__.py
index 9060ecdd..6cabf7ac 100644
--- a/src/pyfly/web/__init__.py
+++ b/src/pyfly/web/__init__.py
@@ -40,6 +40,15 @@
XmlMessageConverter,
default_message_converters,
)
+from pyfly.web.openapi_metadata import (
+ OpenAPIHeader,
+ OpenAPIOperation,
+ OpenAPIParameter,
+ OpenAPIRequestBody,
+ OpenAPIResponse,
+ RouteMetadata,
+ openapi_operation,
+)
from pyfly.web.params import Body, Cookie, File, Form, Header, PathVar, QueryParam, UploadedFile, Valid
from pyfly.web.ports.filter import WebFilter
from pyfly.web.security_headers import SecurityHeadersConfig
@@ -48,6 +57,13 @@
from pyfly.web.views import ModelAndView, Redirect
__all__ = [
+ "OpenAPIHeader",
+ "OpenAPIOperation",
+ "OpenAPIParameter",
+ "OpenAPIRequestBody",
+ "OpenAPIResponse",
+ "RouteMetadata",
+ "openapi_operation",
"ModelAndView",
"Redirect",
"Body",
diff --git a/src/pyfly/web/adapters/starlette/app.py b/src/pyfly/web/adapters/starlette/app.py
index 7ec4a9ab..559d65ca 100644
--- a/src/pyfly/web/adapters/starlette/app.py
+++ b/src/pyfly/web/adapters/starlette/app.py
@@ -60,6 +60,7 @@ def create_app(
actuator_enabled: bool | None = None,
cors: CORSConfig | None = None,
lifespan: object | None = None,
+ openapi_generator: OpenAPIGenerator | None = None,
) -> Starlette:
"""Create a Starlette application with PyFly enterprise middleware.
@@ -412,7 +413,7 @@ def _install_indicators() -> None:
if docs_enabled:
from pyfly.web.adapters.starlette.mounted_routes import collect_mounted_routes
- generator = OpenAPIGenerator(title=title, version=version, description=description)
+ generator = openapi_generator or OpenAPIGenerator(title=title, version=version, description=description)
websocket_routes = registrar.collect_websocket_routes(context) if context is not None else []
# The caller's own routes and sub-applications are served beside the controllers; the
# document describes them too, or a diff of it cannot see them go.
diff --git a/src/pyfly/web/adapters/starlette/controller.py b/src/pyfly/web/adapters/starlette/controller.py
index ed8a0535..6c786f29 100644
--- a/src/pyfly/web/adapters/starlette/controller.py
+++ b/src/pyfly/web/adapters/starlette/controller.py
@@ -17,7 +17,6 @@
import inspect
import typing
-from dataclasses import dataclass, field
from typing import Any
from starlette.requests import Request
@@ -26,7 +25,8 @@
from pyfly.web.adapters.starlette.resolver import ParameterResolver
from pyfly.web.adapters.starlette.view_response import dispatch_response
-from pyfly.web.params import Body, Cookie, Header, PathVar, QueryParam, inspect_binding
+from pyfly.web.openapi_metadata import RouteMetadata as RouteMetadata
+from pyfly.web.params import Body, Cookie, Header, PathVar, QueryParam, _Binding, inspect_binding
_MISSING = object()
@@ -42,29 +42,22 @@ def _py_type_to_openapi(t: type) -> str:
return "string"
-@dataclass
-class RouteMetadata:
- """Metadata extracted from a single controller handler method."""
-
- path: str
- http_method: str
- status_code: int
- handler: Any
- handler_name: str
- parameters: list[dict[str, Any]] = field(default_factory=list)
- request_body_model: type | None = None
- return_type: type | None = None
- tag: str = ""
- summary: str = ""
- description: str = ""
- deprecated: bool = False
- media_type: str = "application/json"
- """Media type of the success response.
-
- ``application/json`` for an ordinary mapping, ``text/event-stream`` for an ``@sse_mapping``. SSE is
- plain HTTP — a GET whose body is a stream of events — so it is perfectly describable in OpenAPI, and
- it only ever went missing because the collector looked at a single attribute.
- """
+def _static_handler(cls: type, name: str) -> Any:
+ """Read only ordinary/static/class methods; never execute user descriptors."""
+ value = inspect.getattr_static(cls, name, None)
+ if isinstance(value, (staticmethod, classmethod)):
+ return value.__func__
+ return value if inspect.isfunction(value) else None
+
+
+def _schema_type(hint: Any) -> Any:
+ """Remove binding sentinels while preserving Pydantic's Annotated constraints."""
+ metadata = getattr(hint, "__metadata__", ())
+ if not metadata:
+ return hint
+ inner = typing.get_args(hint)[0]
+ constraints = tuple(item for item in metadata if not isinstance(item, _Binding))
+ return typing.Annotated[(inner, *constraints)] if constraints else inner
async def _maybe_await(result: Any) -> Any:
@@ -108,7 +101,7 @@ def collect_routes(self, ctx: Any) -> list[Route]:
base_path = getattr(cls, "__pyfly_request_mapping__", "")
for attr_name in dir(cls):
- method_obj = getattr(cls, attr_name, None)
+ method_obj = _static_handler(cls, attr_name)
if method_obj is None:
continue
@@ -148,7 +141,7 @@ class — no bean resolution needed.
tag = self._derive_tag(cls)
for attr_name in dir(cls):
- method_obj = getattr(cls, attr_name, None)
+ method_obj = _static_handler(cls, attr_name)
if method_obj is None:
continue
@@ -178,9 +171,16 @@ class — no bean resolution needed.
# Extract parameter metadata and request body model from type hints
params, body_model = self._extract_param_metadata(method_obj)
+ hints = typing.get_type_hints(method_obj, include_extras=True)
+ parameter_types = {}
+ for name, hint in hints.items():
+ binding, _inner, _valid = inspect_binding(hint)
+ location = {PathVar: "path", QueryParam: "query", Header: "header", Cookie: "cookie"}.get(binding)
+ if location is not None:
+ wire_name = name.replace("_", "-") if binding is Header else name
+ parameter_types[location, wire_name] = _schema_type(hint)
# Extract return type
- hints = typing.get_type_hints(method_obj, include_extras=True)
return_type = hints.get("return")
from pyfly.web.views import ModelAndView
@@ -208,6 +208,9 @@ class — no bean resolution needed.
description=description,
deprecated=deprecated,
media_type=media_type,
+ mapping_name=mapping.get("name") if mapping is not None else None,
+ operation=getattr(method_obj, "__pyfly_openapi__", None),
+ parameter_types=parameter_types,
)
)
@@ -232,7 +235,7 @@ def collect_websocket_routes(self, ctx: Any) -> list[dict[str, str]]:
base_path = getattr(cls, "__pyfly_request_mapping__", "")
for attr_name in dir(cls):
- method_obj = getattr(cls, attr_name, None)
+ method_obj = _static_handler(cls, attr_name)
ws_mapping = getattr(method_obj, "__pyfly_ws_mapping__", None) if method_obj else None
if ws_mapping is None:
@@ -277,69 +280,34 @@ def _parse_docstring(handler: Any) -> tuple[str, str]:
description = "\n".join(line.strip() for line in lines[2:]).strip()
return summary, description
- def _extract_param_metadata(self, handler: Any) -> tuple[list[dict[str, Any]], type | None]:
- """Extract OpenAPI parameter dicts and request body model from handler type hints."""
- from pydantic import BaseModel
-
+ def _extract_param_metadata(self, handler: Any) -> tuple[list[dict[str, Any]], Any]:
+ """Keep legacy parameter dictionaries alongside rich types on RouteMetadata."""
hints = typing.get_type_hints(handler, include_extras=True)
- sig = inspect.signature(handler)
params: list[dict[str, Any]] = []
- body_model: type | None = None
-
- for name, param in sig.parameters.items():
- if name == "self":
- continue
-
+ body_model: Any = None
+ for name, param in inspect.signature(handler).parameters.items():
hint = hints.get(name)
if hint is None:
continue
-
binding, inner_type, _validate = inspect_binding(hint)
- if binding is None:
+ if binding is Body:
+ body_model = _schema_type(hint)
+ continue
+ location = {PathVar: "path", QueryParam: "query", Header: "header", Cookie: "cookie"}.get(binding)
+ if location is None:
continue
-
default = param.default if param.default is not inspect.Parameter.empty else _MISSING
-
- if binding is PathVar:
- params.append(
- {
- "name": name,
- "in": "path",
- "required": True,
- "schema": {"type": _py_type_to_openapi(inner_type)},
- }
- )
- elif binding is QueryParam:
- p: dict[str, Any] = {
- "name": name,
- "in": "query",
- "required": default is _MISSING,
- "schema": {"type": _py_type_to_openapi(inner_type)},
- }
- if default is not _MISSING:
- p["schema"]["default"] = default
- params.append(p)
- elif binding is Header:
- params.append(
- {
- "name": name.replace("_", "-"),
- "in": "header",
- "required": default is _MISSING,
- "schema": {"type": _py_type_to_openapi(inner_type)},
- }
- )
- elif binding is Cookie:
- params.append(
- {
- "name": name,
- "in": "cookie",
- "required": default is _MISSING,
- "schema": {"type": _py_type_to_openapi(inner_type)},
- }
- )
- elif binding is Body and isinstance(inner_type, type) and issubclass(inner_type, BaseModel):
- body_model = inner_type
-
+ permits_none = inner_type is type(None) or type(None) in typing.get_args(inner_type)
+ required = binding is PathVar or (default is _MISSING and not permits_none)
+ item: dict[str, Any] = {
+ "name": name.replace("_", "-") if binding is Header else name,
+ "in": location,
+ "required": required,
+ "schema": {"type": _py_type_to_openapi(inner_type)},
+ }
+ if default is not _MISSING:
+ item["schema"]["default"] = default
+ params.append(item)
return params, body_model
def _collect_exception_handlers(self, instance: Any) -> dict[type[Exception], Any]:
diff --git a/src/pyfly/web/openapi.py b/src/pyfly/web/openapi.py
index 4852da2c..03e85525 100644
--- a/src/pyfly/web/openapi.py
+++ b/src/pyfly/web/openapi.py
@@ -16,16 +16,26 @@
from __future__ import annotations
import re
-from typing import TYPE_CHECKING, Any, cast, get_args, get_origin
-
-from pydantic import BaseModel
+from collections.abc import Mapping
+from copy import deepcopy
+from typing import TYPE_CHECKING, Any
+
+from pydantic.json_schema import GenerateJsonSchema, JsonSchemaMode
+
+from pyfly.web.openapi_metadata import (
+ UNSET,
+ OpenAPIOperation,
+ OpenAPIRequestBody,
+ OpenAPIResponse,
+ RouteMetadata,
+ SecurityRequirements,
+ _validate_security,
+)
+from pyfly.web.openapi_schema import SchemaRegistry
if TYPE_CHECKING:
- from pyfly.web.adapters.starlette.controller import RouteMetadata
from pyfly.web.adapters.starlette.mounted_routes import MountedRoute
-_PATH_PARAM_RE = re.compile(r"\{(\w+)\}")
-
# Standard validation error schema (matches FastAPI's 422 response)
_VALIDATION_ERROR_SCHEMA = {
"title": "ValidationError",
@@ -66,26 +76,16 @@
def _path_slug(path: str) -> str:
- """``/api/telegram/{botId}/updates`` → ``api_telegram_botId_updates``: the part of a
- qualified operationId that names the path, with nothing an identifier cannot carry."""
return re.sub(r"[^A-Za-z0-9]+", "_", path).strip("_")
class OpenAPIGenerator:
- """Generate an OpenAPI 3.1 specification dict.
-
- Automatically derives:
- - Response schemas from handler return type hints
- - Tags from ``@rest_controller`` class names
- - Summary/description from handler docstrings
- - Validation error responses for endpoints with request bodies
- - Pydantic model schemas with proper ``$ref`` resolution
+ """Generate OpenAPI 3.1 without acquiring beans or invoking handlers.
- Usage::
-
- gen = OpenAPIGenerator(title="My API", version="1.0.0")
- spec = gen.generate() # empty paths
- spec = gen.generate(route_metadata=metadata) # real paths
+ All typed inputs share Pydantic definitions. Requests/parameters use validation
+ mode; responses/headers use serialization mode, both by alias. schema_generator
+ accepts a GenerateJsonSchema subclass. Security settings describe documentation
+ only; use runtime security filters to enforce authentication and authorization.
"""
def __init__(
@@ -93,11 +93,18 @@ def __init__(
title: str,
version: str,
description: str = "",
+ *,
+ schema_generator: type[GenerateJsonSchema] = GenerateJsonSchema,
+ security_schemes: Mapping[str, dict[str, Any]] | None = None,
+ security: SecurityRequirements | None = None,
) -> None:
self._title = title
self._version = version
self._description = description
- self._schemas: dict[str, Any] = {}
+ self._schema_generator = schema_generator
+ self._security_schemes = deepcopy(dict(security_schemes or {}))
+ _validate_security(security)
+ self._security = deepcopy(security)
def generate(
self,
@@ -105,294 +112,215 @@ def generate(
websocket_routes: list[dict[str, str]] | None = None,
mounted_routes: list[MountedRoute] | None = None,
) -> dict[str, Any]:
- """Generate a complete OpenAPI 3.1 spec as a dict.
-
- ``websocket_routes`` — from ``ControllerRegistrar.collect_websocket_routes()`` — is published
- under the ``x-pyfly-websocket-routes`` extension rather than as operations. WebSocket has no
- OpenAPI representation, but leaving it out entirely made the document quietly incomplete: a
- service could delete a socket route and a CI diff of /openapi.json would report no change.
+ """Build a fresh document; controller operations take precedence over mounts.
- ``mounted_routes`` — from ``collect_mounted_routes()`` over ``create_app(extra_routes=...)`` —
- are the plain Starlette routes and mounted sub-applications served beside the controllers.
- They become operations marked ``x-pyfly-mounted: true`` (no typed contract can be read from
- an ASGI endpoint) with their path parameters declared and a unique ``operationId``, and
- never overwrite a controller's operation on the same path and method; a mount that could
- not be walked is listed under ``x-pyfly-mounts``. Same reason as above:
- a service whose webhooks live in sub-apps had a document that described none of them.
+ Duplicate controller path/method declarations and explicit operation IDs fail.
+ Implicit ID collisions are resolved in sorted path/method order, controllers
+ first, with suffixes checked against every preferred name in the document.
+ Opaque mounts and WebSockets retain their x-pyfly-* inventory extensions.
"""
- self._schemas = {}
-
+ registry = SchemaRegistry(self._schema_generator)
paths: dict[str, Any] = {}
- tags: list[dict[str, str]] = []
- if route_metadata:
- paths = self._build_paths(route_metadata)
- tags = self._collect_tags(route_metadata)
+ entries: list[tuple[str, str, str, bool, bool]] = []
+ validation_keys: list[tuple[str, str]] = []
+ for meta in sorted(route_metadata or (), key=lambda item: (item.path, item.http_method.lower())):
+ method = meta.http_method.lower()
+ if method in paths.get(meta.path, {}):
+ raise ValueError(f"Duplicate controller operation: {method.upper()} {meta.path}")
+ override = meta.operation or OpenAPIOperation()
+ operation = self._operation(meta, override, registry)
+ paths.setdefault(meta.path, {})[method] = operation
+ preferred = override.operation_id or meta.mapping_name or meta.handler_name
+ entries.append((meta.path, method, preferred, override.operation_id is not None, False))
+ if operation["responses"].get("422") is _INFERRED_VALIDATION:
+ validation_keys.append((meta.path, method))
+ operation["responses"].pop("422")
- # operationIds must be unique across the document (OpenAPI 3.1). A mounted route's name
- # is its endpoint's ``__name__`` unless the route was named, and six sub-applications
- # each carrying a ``health`` endpoint are the normal case, not the exception. The first
- # holder of a name keeps it (the controllers' ids are taken first, so a controller never
- # loses its id to a mounted twin); every later one is qualified by method and path,
- # which is deterministic, so a diff of the document stays stable.
- taken: set[str] = {
- str(operation.get("operationId"))
- for operations in paths.values()
- for operation in operations.values()
- if isinstance(operation, dict) and operation.get("operationId")
- }
opaque_mounts: list[dict[str, str]] = []
- for mounted in mounted_routes or ():
+ for mounted in sorted(mounted_routes or (), key=lambda item: (item.path, item.method or "", item.name)):
if mounted.method is None:
opaque_mounts.append({"path": mounted.path, "name": mounted.name})
continue
- operations = paths.setdefault(mounted.path, {})
- method_key = mounted.method.lower()
- if method_key in operations:
+ method = mounted.method.lower()
+ if method in paths.get(mounted.path, {}):
continue
- operation_id = mounted.name
- if operation_id in taken:
- operation_id = f"{mounted.name}_{method_key}_{_path_slug(mounted.path)}"
- taken.add(operation_id)
- operation: dict[str, Any] = {"operationId": operation_id}
+ operation = {"responses": {"default": {"description": "Successful response"}}, "x-pyfly-mounted": True}
if mounted.summary:
operation["summary"] = mounted.summary
if mounted.parameters:
operation["parameters"] = [parameter.to_openapi() for parameter in mounted.parameters]
- operation["responses"] = {"default": {"description": "Successful response"}}
- operation["x-pyfly-mounted"] = True
- operations[method_key] = operation
-
- spec: dict[str, Any] = {
- "openapi": "3.1.0",
- "info": self._build_info(),
- "paths": paths,
- }
-
+ paths.setdefault(mounted.path, {})[method] = operation
+ entries.append((mounted.path, method, mounted.name, False, True))
+ self._assign_ids(paths, entries)
+ info = {"title": self._title, "version": self._version}
+ if self._description:
+ info["description"] = self._description
+ spec: dict[str, Any] = {"openapi": "3.1.0", "info": info, "paths": paths}
+ tags = sorted({tag for ops in paths.values() for op in ops.values() for tag in op.get("tags", [])})
if tags:
- spec["tags"] = tags
-
- if self._schemas:
- spec["components"] = {"schemas": self._schemas}
-
+ spec["tags"] = [{"name": tag} for tag in tags]
+ if self._security is not None:
+ spec["security"] = [dict(requirement) for requirement in self._security]
if websocket_routes:
- spec["x-pyfly-websocket-routes"] = websocket_routes
-
+ spec["x-pyfly-websocket-routes"] = deepcopy(websocket_routes)
if opaque_mounts:
spec["x-pyfly-mounts"] = opaque_mounts
+ # Inject built-in errors after user names are known: user HTTPValidationError
+ # models must never overwrite the built-in error (or vice versa).
+ spec, schemas = registry.resolve(spec)
+ if validation_keys:
+ names: list[str] = []
+ for base in ("ValidationError", "HTTPValidationError"):
+ name = base
+ suffix = 2
+ while name in schemas or name in names:
+ name = f"{base}_{suffix}"
+ suffix += 1
+ names.append(name)
+ schemas[names[0]] = deepcopy(_VALIDATION_ERROR_SCHEMA)
+ schemas[names[1]] = deepcopy(_HTTP_VALIDATION_ERROR_SCHEMA)
+ schemas[names[1]]["properties"]["detail"]["items"]["$ref"] = f"#/components/schemas/{names[0]}"
+ for path, method in validation_keys:
+ spec["paths"][path][method]["responses"]["422"] = {
+ "description": "Validation Error",
+ "content": {"application/json": {"schema": {"$ref": f"#/components/schemas/{names[1]}"}}},
+ }
+ components: dict[str, Any] = {}
+ if schemas:
+ components["schemas"] = schemas
+ if self._security_schemes:
+ components["securitySchemes"] = deepcopy(self._security_schemes)
+ if components:
+ spec["components"] = components
return spec
- # ------------------------------------------------------------------
- # Info
- # ------------------------------------------------------------------
-
- def _build_info(self) -> dict[str, Any]:
- info: dict[str, Any] = {
- "title": self._title,
- "version": self._version,
- }
- if self._description:
- info["description"] = self._description
- return info
-
- # ------------------------------------------------------------------
- # Tags
- # ------------------------------------------------------------------
-
@staticmethod
- def _collect_tags(route_metadata: list[RouteMetadata]) -> list[dict[str, str]]:
- """Collect unique tags from route metadata, preserving discovery order."""
- seen: set[str] = set()
- tags: list[dict[str, str]] = []
- for meta in route_metadata:
- if meta.tag and meta.tag not in seen:
- seen.add(meta.tag)
- tags.append({"name": meta.tag})
- return tags
-
- # ------------------------------------------------------------------
- # Paths
- # ------------------------------------------------------------------
-
- def _build_paths(self, route_metadata: list[RouteMetadata]) -> dict[str, Any]:
- """Build the ``paths`` dict from a list of RouteMetadata."""
- paths: dict[str, Any] = {}
-
- for meta in route_metadata:
- path = meta.path
- method_key = meta.http_method.lower()
-
- if path not in paths:
- paths[path] = {}
-
- operation: dict[str, Any] = {
- "operationId": meta.handler_name,
- "responses": self._build_responses(meta),
- }
-
- # Tag
- if meta.tag:
- operation["tags"] = [meta.tag]
-
- # Summary and description from docstrings
- if meta.summary:
- operation["summary"] = meta.summary
- if meta.description:
- operation["description"] = meta.description
-
- # Deprecated
- if meta.deprecated:
- operation["deprecated"] = True
-
- # Parameters
- if meta.parameters:
- operation["parameters"] = meta.parameters
-
- # Request body
- if meta.request_body_model is not None:
- ref = self._register_model(meta.request_body_model)
- operation["requestBody"] = {
- "required": True,
- "content": {
- "application/json": {
- "schema": {"$ref": ref},
- }
- },
+ def _assign_ids(paths: dict[str, Any], entries: list[tuple[str, str, str, bool, bool]]) -> None:
+ explicit: set[str] = set()
+ for _path, _method, preferred, is_explicit, _mounted in entries:
+ if is_explicit:
+ if preferred in explicit:
+ raise ValueError(f"Duplicate explicit operation ID: {preferred}")
+ explicit.add(preferred)
+ reserved = {entry[2] for entry in entries}
+ taken = set(explicit)
+ for path, method, preferred, is_explicit, _mounted in sorted(entries, key=lambda e: (e[4], e[0], e[1])):
+ if is_explicit:
+ operation_id = preferred
+ elif preferred not in taken:
+ operation_id = preferred
+ taken.add(operation_id)
+ else:
+ base = f"{preferred}_{method}_{_path_slug(path)}"
+ operation_id = base
+ suffix = 2
+ while operation_id in taken or operation_id in reserved:
+ operation_id = f"{base}_{suffix}"
+ suffix += 1
+ taken.add(operation_id)
+ paths[path][method]["operationId"] = operation_id
+
+ def _operation(self, meta: RouteMetadata, override: OpenAPIOperation, registry: SchemaRegistry) -> dict[str, Any]:
+ context = f"{meta.http_method.upper()} {meta.path}"
+ operation: dict[str, Any] = {}
+ for key in ("summary", "description", "deprecated"):
+ value = getattr(override, key)
+ if value is None:
+ value = getattr(meta, key)
+ if value:
+ operation[key] = value
+ tags = override.tags if override.tags is not None else ([meta.tag] if meta.tag else [])
+ if tags:
+ operation["tags"] = list(tags)
+ if override.security is not None:
+ operation["security"] = deepcopy([dict(requirement) for requirement in override.security])
+
+ parameters: list[dict[str, Any]] = []
+ if override.parameters is not None:
+ for parameter in override.parameters:
+ item: dict[str, Any] = {
+ "name": parameter.name,
+ "in": parameter.location,
+ "required": parameter.required,
+ "schema": registry.schema(
+ parameter.schema, "validation", context=context, default=parameter.default
+ ),
}
-
- paths[path][method_key] = operation
-
- return paths
-
- # ------------------------------------------------------------------
- # Responses
- # ------------------------------------------------------------------
-
- def _build_responses(self, meta: RouteMetadata) -> dict[str, Any]:
- """Build the ``responses`` dict for a single operation."""
- status = str(meta.status_code)
- responses: dict[str, Any] = {}
-
- if meta.status_code == 204:
- responses[status] = {"description": "No Content"}
- elif meta.return_type is not None and self._is_pydantic_model(meta.return_type):
- ref = self._register_model(meta.return_type)
- responses[status] = {
- "description": "Successful response",
- "content": {
- "application/json": {
- "schema": {"$ref": ref},
- }
- },
- }
- elif meta.return_type is not None and self._is_list_of_pydantic(meta.return_type):
- inner = self._get_list_inner_type(meta.return_type)
- ref = self._register_model(inner)
- responses[status] = {
- "description": "Successful response",
- "content": {
- "application/json": {
- "schema": {
- "type": "array",
- "items": {"$ref": ref},
- }
- }
- },
- }
- elif meta.media_type != "application/json":
- # An @sse_mapping: the body is a stream of text/event-stream frames, not a JSON document,
- # and saying so is the difference between a described stream and an undescribed one.
- responses[status] = {
- "description": "Event stream",
- "content": {meta.media_type: {"schema": {"type": "string"}}},
- }
+ if parameter.description:
+ item["description"] = parameter.description
+ parameters.append(item)
else:
- responses[status] = {"description": "Successful response"}
-
- # Add 422 Validation Error for endpoints with request bodies
- if meta.request_body_model is not None:
- self._ensure_validation_schemas()
- responses["422"] = {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {"$ref": "#/components/schemas/HTTPValidationError"},
- }
- },
+ parameters = deepcopy(meta.parameters)
+ for item in parameters:
+ identity = (item["in"], item["name"])
+ if identity in meta.parameter_types:
+ item["schema"] = registry.schema(
+ meta.parameter_types[identity],
+ "validation",
+ context=context,
+ default=item.get("schema", {}).get("default", UNSET),
+ )
+ if parameters:
+ operation["parameters"] = parameters
+
+ body = override.request_body
+ if body is UNSET and meta.request_body_model is not None:
+ body = OpenAPIRequestBody(content={"application/json": meta.request_body_model})
+ if isinstance(body, OpenAPIRequestBody):
+ operation["requestBody"] = {
+ "required": body.required,
+ "content": self._content(body.content, "validation", registry, context),
}
+ if body.description:
+ operation["requestBody"]["description"] = body.description
- return responses
-
- # ------------------------------------------------------------------
- # Schema registration
- # ------------------------------------------------------------------
-
- def _register_model(self, model: type) -> str:
- """Register a Pydantic model in ``components.schemas`` and return a ``$ref`` string.
-
- Handles Pydantic v2's ``$defs`` by hoisting nested model schemas
- into ``components/schemas`` and rewriting internal ``$ref`` paths.
- """
- name = model.__name__
- if name not in self._schemas:
- schema = model.model_json_schema() # type: ignore[attr-defined]
-
- # Hoist $defs into components/schemas
- defs = schema.pop("$defs", None)
- if defs:
- for def_name, def_schema in defs.items():
- if def_name not in self._schemas:
- self._schemas[def_name] = self._rewrite_refs(def_schema)
-
- self._schemas[name] = self._rewrite_refs(schema)
-
- return f"#/components/schemas/{name}"
-
- def _ensure_validation_schemas(self) -> None:
- """Register the standard validation error schemas if not already present."""
- if "ValidationError" not in self._schemas:
- self._schemas["ValidationError"] = _VALIDATION_ERROR_SCHEMA
- if "HTTPValidationError" not in self._schemas:
- self._schemas["HTTPValidationError"] = _HTTP_VALIDATION_ERROR_SCHEMA
-
- @staticmethod
- def _rewrite_refs(schema: Any) -> Any:
- """Rewrite ``$ref: #/$defs/Name`` → ``$ref: #/components/schemas/Name``."""
- if isinstance(schema, dict):
- result = {}
- for key, value in schema.items():
- if key == "$ref" and isinstance(value, str) and value.startswith("#/$defs/"):
- result[key] = value.replace("#/$defs/", "#/components/schemas/")
- else:
- result[key] = OpenAPIGenerator._rewrite_refs(value)
- return result
- if isinstance(schema, list):
- return [OpenAPIGenerator._rewrite_refs(item) for item in schema]
- return schema
-
- # ------------------------------------------------------------------
- # Type introspection helpers
- # ------------------------------------------------------------------
-
- @staticmethod
- def _is_pydantic_model(t: type) -> bool:
- """Check if a type is a Pydantic BaseModel subclass."""
- try:
- return isinstance(t, type) and issubclass(t, BaseModel)
- except TypeError:
- return False
-
- @staticmethod
- def _is_list_of_pydantic(t: Any) -> bool:
- """Check if a type is ``list[SomePydanticModel]``."""
- origin = get_origin(t)
- if origin is list:
- args = get_args(t)
- if args:
- return OpenAPIGenerator._is_pydantic_model(args[0])
- return False
+ responses: dict[str, Any] = {}
+ if not override.replace_responses:
+ status = str(meta.status_code)
+ # A status override also suppresses generation of its inferred schema.
+ if not any(str(key) == status for key in override.responses or {}):
+ response: dict[str, Any] = {"description": "Successful response"}
+ if meta.status_code == 204:
+ response = {"description": "No Content"}
+ elif meta.media_type != "application/json":
+ response = {
+ "description": "Event stream",
+ "content": {meta.media_type: {"schema": {"type": "string"}}},
+ }
+ elif meta.return_type is not None and meta.return_type is not type(None):
+ schema = registry.schema(meta.return_type, "serialization", context=context, explicit=False)
+ if schema is not None:
+ response["content"] = {"application/json": {"schema": schema}}
+ responses[status] = response
+ if "requestBody" in operation:
+ responses["422"] = _INFERRED_VALIDATION
+ for status_key, response_override in (override.responses or {}).items():
+ responses[str(status_key)] = self._response(response_override, registry, context)
+ operation["responses"] = responses
+ return operation
@staticmethod
- def _get_list_inner_type(t: Any) -> type:
- """Extract the inner type from ``list[T]``."""
- return cast(type, get_args(t)[0])
+ def _content(
+ content: Mapping[str, Any], mode: JsonSchemaMode, registry: SchemaRegistry, context: str
+ ) -> dict[str, Any]:
+ return {media: {"schema": registry.schema(schema, mode, context=context)} for media, schema in content.items()}
+
+ def _response(self, response: OpenAPIResponse, registry: SchemaRegistry, context: str) -> dict[str, Any]:
+ result: dict[str, Any] = {"description": response.description}
+ if response.content:
+ result["content"] = self._content(response.content, "serialization", registry, context)
+ if response.headers:
+ headers: dict[str, Any] = {}
+ for name, header in response.headers.items():
+ item: dict[str, Any] = {"schema": registry.schema(header.schema, "serialization", context=context)}
+ if header.description:
+ item["description"] = header.description
+ if header.required:
+ item["required"] = True
+ headers[name] = item
+ result["headers"] = headers
+ return result
+
+
+_INFERRED_VALIDATION = object()
diff --git a/src/pyfly/web/openapi_metadata.py b/src/pyfly/web/openapi_metadata.py
new file mode 100644
index 00000000..61d094ca
--- /dev/null
+++ b/src/pyfly/web/openapi_metadata.py
@@ -0,0 +1,282 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Framework-neutral, documentation-only OpenAPI contracts.
+
+Schema inputs are Python types, annotated aliases, or Pydantic TypeAdapter instances.
+They describe the wire format; they never change binding, validation, or authorization.
+"""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Callable, Mapping, Sequence
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Any, Literal, TypeVar
+
+F = TypeVar("F", bound=Callable[..., Any])
+
+
+class _Unset(Enum):
+ VALUE = "unset"
+
+
+UNSET = _Unset.VALUE
+SecurityRequirements = Sequence[Mapping[str, Sequence[str]]]
+
+
+def _validate_security(security: SecurityRequirements | None) -> None:
+ if security is None:
+ return
+ if isinstance(security, (str, bytes)) or not isinstance(security, Sequence):
+ raise TypeError("security must be a sequence of security requirement mappings")
+ for requirement in security:
+ if not isinstance(requirement, Mapping):
+ raise TypeError("security requirements must be mappings")
+ for name, scopes in requirement.items():
+ if not isinstance(name, str) or not name:
+ raise ValueError("security scheme names must be nonempty strings")
+ if isinstance(scopes, (str, bytes)) or not isinstance(scopes, Sequence):
+ raise TypeError("security scopes must be sequences of strings")
+ if any(not isinstance(scope, str) for scope in scopes):
+ raise TypeError("security scopes must be strings")
+
+
+def _validate_description_required(description: str, required: bool) -> None:
+ if not isinstance(description, str):
+ raise TypeError("description must be a string")
+ if not isinstance(required, bool):
+ raise TypeError("required must be a bool")
+
+
+def _validate_content(content: Mapping[str, Any]) -> None:
+ if not isinstance(content, Mapping):
+ raise TypeError("content must map media types to Python schema types or TypeAdapters")
+ for media_type in content:
+ if not isinstance(media_type, str) or "/" not in media_type:
+ raise ValueError("content keys must be media types, for example application/json")
+
+
+@dataclass(frozen=True)
+class OpenAPIParameter:
+ """Document a parameter; ``default`` is omitted unless explicitly supplied.
+
+ Path parameters must be required. ``schema`` is a Python type or TypeAdapter,
+ not a raw JSON schema. Explicit operation parameters replace the inferred list.
+ """
+
+ name: str
+ location: Literal["path", "query", "header", "cookie"]
+ schema: Any
+ required: bool = True
+ description: str = ""
+ default: Any = UNSET
+
+ def __post_init__(self) -> None:
+ _validate_description_required(self.description, self.required)
+ if not isinstance(self.name, str) or not self.name:
+ raise ValueError("parameter name must be a nonempty string")
+ if self.location not in ("path", "query", "header", "cookie"):
+ raise ValueError("parameter location must be path, query, header, or cookie")
+ if self.location == "path" and not self.required:
+ raise ValueError("path parameters must be required")
+
+
+@dataclass(frozen=True)
+class OpenAPIHeader:
+ """Response header schema, generated in serialization mode."""
+
+ schema: Any
+ description: str = ""
+ required: bool = False
+
+ def __post_init__(self) -> None:
+ _validate_description_required(self.description, self.required)
+
+
+@dataclass(frozen=True)
+class OpenAPIRequestBody:
+ """Request content maps media types to Python types or TypeAdapters.
+
+ ``required`` concerns the presence of the body, independently of nullable values.
+ """
+
+ content: Mapping[str, Any]
+ required: bool = True
+ description: str = ""
+
+ def __post_init__(self) -> None:
+ _validate_description_required(self.description, self.required)
+ _validate_content(self.content)
+ if not self.content:
+ raise ValueError("request body content must not be empty; use request_body=None to omit it")
+
+
+@dataclass(frozen=True)
+class OpenAPIResponse:
+ """A complete response for one status; empty content documents a bodyless response."""
+
+ description: str
+ content: Mapping[str, Any] = field(default_factory=dict)
+ headers: Mapping[str, OpenAPIHeader] = field(default_factory=dict)
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.description, str):
+ raise TypeError("response description must be a string")
+ _validate_content(self.content)
+ if not isinstance(self.headers, Mapping):
+ raise TypeError("response headers must map names to OpenAPIHeader values")
+ for name, header in self.headers.items():
+ if not isinstance(name, str) or not name or not isinstance(header, OpenAPIHeader):
+ raise TypeError("response headers must map nonempty names to OpenAPIHeader values")
+
+
+@dataclass(frozen=True)
+class OpenAPIOperation:
+ """Explicit overrides of inferred operation documentation.
+
+ Unset request_body infers it; None omits it. None parameters/tags infer them;
+ empty sequences omit them. None summary/description/deprecated infer them;
+ empty strings/False suppress them. None security inherits global security;
+ [] explicitly declares no security. These settings never enforce authentication.
+
+ Responses merge with inferred statuses by default, replacing each declared status
+ completely (including 422). replace_responses=True replaces the entire response
+ map and requires at least one response. Status keys accept 100..599, default,
+ and OpenAPI ranges such as 2XX. operation_id overrides mapping name and handler name.
+ """
+
+ operation_id: str | None = None
+ summary: str | None = None
+ description: str | None = None
+ tags: Sequence[str] | None = None
+ deprecated: bool | None = None
+ parameters: Sequence[OpenAPIParameter] | None = None
+ request_body: OpenAPIRequestBody | None | _Unset = UNSET
+ responses: Mapping[int | str, OpenAPIResponse] | None = None
+ replace_responses: bool = False
+ security: SecurityRequirements | None = None
+
+ def __post_init__(self) -> None:
+ if self.operation_id is not None and (not isinstance(self.operation_id, str) or not self.operation_id.strip()):
+ raise ValueError("operation_id must be a nonempty string")
+ for name in ("summary", "description"):
+ value = getattr(self, name)
+ if value is not None and not isinstance(value, str):
+ raise TypeError(f"{name} must be a string or None")
+ if self.tags is not None and (
+ not isinstance(self.tags, Sequence)
+ or isinstance(self.tags, (str, bytes))
+ or any(not isinstance(tag, str) for tag in self.tags)
+ ):
+ raise TypeError("tags must be a sequence of strings")
+ if self.deprecated is not None and not isinstance(self.deprecated, bool):
+ raise TypeError("deprecated must be a bool or None")
+ if self.parameters is not None:
+ if not isinstance(self.parameters, Sequence) or any(
+ not isinstance(param, OpenAPIParameter) for param in self.parameters
+ ):
+ raise TypeError("parameters must be a sequence of OpenAPIParameter values")
+ identities = [(param.location, param.name) for param in self.parameters]
+ if len(identities) != len(set(identities)):
+ raise ValueError("duplicate explicit parameters")
+ if (
+ self.request_body is not UNSET
+ and self.request_body is not None
+ and not isinstance(self.request_body, OpenAPIRequestBody)
+ ):
+ raise TypeError("request_body must be OpenAPIRequestBody, None, or unset")
+ if self.responses is not None:
+ if not isinstance(self.responses, Mapping):
+ raise TypeError("responses must map status codes to OpenAPIResponse values")
+ seen: set[str] = set()
+ for status, response in self.responses.items():
+ if not re.fullmatch(r"[1-5](?:[0-9]{2}|XX)|default", str(status)):
+ raise ValueError(f"invalid OpenAPI response status: {status!r}")
+ if str(status) in seen:
+ raise ValueError(f"duplicate response status: {status}")
+ seen.add(str(status))
+ if not isinstance(response, OpenAPIResponse):
+ raise TypeError("responses must contain OpenAPIResponse values")
+ if not isinstance(self.replace_responses, bool):
+ raise TypeError("replace_responses must be a bool")
+ if self.replace_responses and not self.responses:
+ raise ValueError("replace_responses requires at least one explicit response")
+ _validate_security(self.security)
+
+
+def openapi_operation(
+ *,
+ operation_id: str | None = None,
+ summary: str | None = None,
+ description: str | None = None,
+ tags: Sequence[str] | None = None,
+ deprecated: bool | None = None,
+ parameters: Sequence[OpenAPIParameter] | None = None,
+ request_body: OpenAPIRequestBody | None | _Unset = UNSET,
+ responses: Mapping[int | str, OpenAPIResponse] | None = None,
+ replace_responses: bool = False,
+ security: SecurityRequirements | None = None,
+) -> Callable[[F], F]:
+ """Attach OpenAPIOperation metadata without wrapping or modifying the signature.
+
+ Accepts the same overrides as OpenAPIOperation; see that type for merge/unset
+ semantics. May appear above or below an HTTP mapping decorator.
+ """
+ operation = OpenAPIOperation(
+ operation_id=operation_id,
+ summary=summary,
+ description=description,
+ tags=tags,
+ deprecated=deprecated,
+ parameters=parameters,
+ request_body=request_body,
+ responses=responses,
+ replace_responses=replace_responses,
+ security=security,
+ )
+
+ def decorate(func: F) -> F:
+ target = func.__func__ if isinstance(func, (staticmethod, classmethod)) else func
+ target.__pyfly_openapi__ = operation # type: ignore[attr-defined]
+ return func
+
+ return decorate
+
+
+@dataclass
+class RouteMetadata:
+ """Offline operation input; no framework, context or handler invocation is needed.
+
+ Existing raw parameter dictionaries remain supported. parameter_types optionally
+ supplies Python schema inputs by (location, name), retaining richer annotations.
+ operation overrides inference, and mapping_name is the routing decorator's name.
+ """
+
+ path: str
+ http_method: str
+ status_code: int
+ handler: Any
+ handler_name: str
+ parameters: list[dict[str, Any]] = field(default_factory=list)
+ request_body_model: Any = None
+ return_type: Any = None
+ tag: str = ""
+ summary: str = ""
+ description: str = ""
+ deprecated: bool = False
+ media_type: str = "application/json"
+ mapping_name: str | None = None
+ operation: OpenAPIOperation | None = None
+ parameter_types: dict[tuple[str, str], Any] = field(default_factory=dict)
diff --git a/src/pyfly/web/openapi_schema.py b/src/pyfly/web/openapi_schema.py
new file mode 100644
index 00000000..b78578e7
--- /dev/null
+++ b/src/pyfly/web/openapi_schema.py
@@ -0,0 +1,79 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Shared Pydantic schema generation for one OpenAPI document."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from pydantic import PydanticUserError, TypeAdapter
+from pydantic.json_schema import GenerateJsonSchema, JsonSchemaMode
+
+from pyfly.web.openapi_metadata import UNSET
+
+
+@dataclass
+class _SchemaSlot:
+ key: int
+ mode: JsonSchemaMode
+ default: Any = UNSET
+
+
+class SchemaRegistry:
+ """Defer schemas until every input is known, so Pydantic resolves name collisions."""
+
+ def __init__(self, schema_generator: type[GenerateJsonSchema]) -> None:
+ self._generator = schema_generator
+ self._inputs: list[tuple[int, JsonSchemaMode, TypeAdapter[Any]]] = []
+
+ def schema(
+ self, schema_type: Any, mode: JsonSchemaMode, *, context: str, explicit: bool = True, default: Any = UNSET
+ ) -> _SchemaSlot | None:
+ try:
+ adapter = schema_type if isinstance(schema_type, TypeAdapter) else TypeAdapter(schema_type)
+ # Validate separately to provide the responsible route for bad explicit inputs,
+ # and preserve the undocumented fallback for unsupported runtime response types.
+ adapter.json_schema(mode=mode, schema_generator=self._generator)
+ except (PydanticUserError, TypeError, ValueError) as exc:
+ if not explicit:
+ return None
+ raise ValueError(f"Invalid OpenAPI schema for {context}: {exc}") from exc
+ if default is not UNSET:
+ try:
+ default = adapter.dump_python(default, mode="json")
+ except (TypeError, ValueError) as exc:
+ raise ValueError(f"Invalid OpenAPI schema default for {context}: {exc}") from exc
+ key = len(self._inputs)
+ self._inputs.append((key, mode, adapter))
+ return _SchemaSlot(key, mode, default)
+
+ def resolve(self, document: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
+ schemas, definitions = TypeAdapter.json_schemas(
+ self._inputs, by_alias=True, ref_template="#/components/schemas/{model}", schema_generator=self._generator
+ )
+
+ def replace(value: Any) -> Any:
+ if isinstance(value, _SchemaSlot):
+ schema = dict(schemas[value.key, value.mode])
+ if value.default is not UNSET:
+ schema["default"] = value.default
+ return schema
+ if isinstance(value, dict):
+ return {key: replace(child) for key, child in value.items()}
+ if isinstance(value, list):
+ return [replace(child) for child in value]
+ return value
+
+ return replace(document), definitions.get("$defs", {})
diff --git a/tests/web/test_openapi.py b/tests/web/test_openapi.py
index 5cfe2565..b75ce276 100644
--- a/tests/web/test_openapi.py
+++ b/tests/web/test_openapi.py
@@ -624,8 +624,8 @@ async def test_create_response_with_201_status(self):
assert schema["$ref"] == "#/components/schemas/ItemResponse"
@pytest.mark.asyncio
- async def test_dict_return_type_has_no_response_schema(self):
- """When return type is dict, no response body schema is generated."""
+ async def test_dict_return_type_has_object_response_schema(self):
+ """Serializable dict returns have an object schema."""
ctx = ApplicationContext(Config({}))
ctx.register_bean(CatalogService)
ctx.register_bean(CatalogController)
@@ -639,7 +639,7 @@ async def test_dict_return_type_has_no_response_schema(self):
get_op = spec["paths"]["/api/items/{item_id}"]["get"]
resp_200 = get_op["responses"]["200"]
- assert "content" not in resp_200
+ assert resp_200["content"]["application/json"]["schema"] == {"type": "object", "additionalProperties": True}
assert resp_200["description"] == "Successful response"
diff --git a/tests/web/test_openapi_contracts.py b/tests/web/test_openapi_contracts.py
new file mode 100644
index 00000000..94b7d966
--- /dev/null
+++ b/tests/web/test_openapi_contracts.py
@@ -0,0 +1,592 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Offline contracts are accurate without changing request dispatch or starting beans."""
+
+import inspect
+import json
+import subprocess
+import sys
+from enum import StrEnum
+from types import SimpleNamespace
+from typing import Annotated, Literal
+from uuid import UUID
+
+import pytest
+from pydantic import BaseModel, Field, TypeAdapter, create_model, field_serializer
+from pydantic.json_schema import GenerateJsonSchema
+from starlette.applications import Starlette
+from starlette.requests import Request
+from starlette.responses import JSONResponse
+from starlette.testclient import TestClient
+
+import pyfly.web as web
+from pyfly.container.stereotypes import rest_controller
+from pyfly.web import Body, Cookie, Header, PathVar, QueryParam, get_mapping, post_mapping
+from pyfly.web.adapters.starlette.app import create_app
+from pyfly.web.adapters.starlette.controller import ControllerRegistrar, RouteMetadata
+from pyfly.web.adapters.starlette.mounted_routes import MountedRoute
+from pyfly.web.openapi import OpenAPIGenerator
+
+
+class Choice(StrEnum):
+ FIRST = "first"
+ SECOND = "second"
+
+
+class Cat(BaseModel):
+ kind: Literal["cat"]
+ lives: int
+
+
+class Dog(BaseModel):
+ kind: Literal["dog"]
+ bark: bool
+
+
+Pet = Annotated[Cat | Dog, Field(discriminator="kind")]
+
+
+class Tree(BaseModel):
+ children: list["Tree"] = []
+
+
+class Aliased(BaseModel):
+ value: int = Field(validation_alias="input", serialization_alias="output")
+
+ @field_serializer("value")
+ def serialize_value(self, value: int) -> str:
+ return str(value)
+
+
+def context(*classes):
+ class Unstarted:
+ container = SimpleNamespace(_registrations=dict.fromkeys(classes))
+
+ def get_bean(self, cls):
+ raise AssertionError("offline collection resolved a bean")
+
+ return Unstarted()
+
+
+def route(path="/test", name="test", **kwargs):
+ return RouteMetadata(path, "POST", 200, None, name, **kwargs)
+
+
+def generate(*routes, **kwargs):
+ return OpenAPIGenerator("Test", "1", **kwargs).generate(list(routes))
+
+
+def resolve(spec, schema):
+ while "$ref" in schema:
+ schema = spec["components"]["schemas"][schema["$ref"].rsplit("/", 1)[1]]
+ return schema
+
+
+def assert_refs_resolve(spec):
+ def walk(value):
+ if isinstance(value, dict):
+ for key, child in value.items():
+ if key == "$ref":
+ assert child.startswith("#/components/schemas/")
+ assert child.rsplit("/", 1)[1] in spec["components"]["schemas"]
+ if key == "mapping":
+ for target in child.values():
+ assert target.rsplit("/", 1)[1] in spec["components"]["schemas"]
+ assert target.startswith("#/components/schemas/")
+ walk(child)
+ elif isinstance(value, list):
+ for child in value:
+ walk(child)
+
+ walk(spec)
+
+
+@pytest.fixture
+def api():
+ for name in (
+ "openapi_operation",
+ "OpenAPIOperation",
+ "OpenAPIRequestBody",
+ "OpenAPIResponse",
+ "OpenAPIHeader",
+ "OpenAPIParameter",
+ "RouteMetadata",
+ ):
+ assert hasattr(web, name), f"missing public documentation API: {name}"
+ return web
+
+
+@rest_controller
+class TypedController:
+ @post_mapping("/typed/{identifier}", name="probe.typed")
+ def typed(
+ self,
+ identifier: PathVar[UUID],
+ body: Body[list[Pet]],
+ count: QueryParam[Annotated[int, Field(ge=1, le=20)]] = 2,
+ choice: QueryParam[Choice] = Choice.FIRST,
+ token: Header[str | None] = None,
+ cookie: Cookie[UUID | None] = None,
+ ) -> list[Pet]:
+ raise AssertionError("offline generation invoked a controller")
+
+
+def test_mapping_name_and_rich_inference():
+ metadata = ControllerRegistrar().collect_route_metadata(context(TypedController))
+ spec = generate(*metadata)
+ op = spec["paths"]["/typed/{identifier}"]["post"]
+ assert op["operationId"] == "probe.typed"
+ params = {p["name"]: p for p in op["parameters"]}
+ assert params["identifier"]["schema"]["format"] == "uuid"
+ assert params["count"]["schema"] == {"type": "integer", "minimum": 1, "maximum": 20, "default": 2}
+ assert resolve(spec, params["choice"]["schema"])["enum"] == ["first", "second"]
+ assert params["choice"]["schema"]["default"] == "first"
+ assert params["token"]["schema"]["anyOf"] == [{"type": "string"}, {"type": "null"}]
+ assert params["cookie"]["schema"]["default"] is None
+ assert not params["token"]["required"]
+ assert (
+ op["requestBody"]["content"]["application/json"]["schema"]["items"]["discriminator"]["propertyName"] == "kind"
+ )
+ assert op["responses"]["200"]["content"]["application/json"]["schema"]["items"]["oneOf"]
+ assert_refs_resolve(spec)
+ json.dumps(spec)
+
+
+def test_descriptor_inspection_is_static_and_inherited_methods_survive():
+ class Trap:
+ def __get__(self, obj, owner):
+ raise AssertionError("descriptor evaluated")
+
+ class Parent:
+ trap = Trap()
+
+ @get_mapping("/ordinary")
+ def ordinary(self) -> str:
+ return "ok"
+
+ @staticmethod
+ @get_mapping("/static")
+ def static() -> str:
+ return "ok"
+
+ @classmethod
+ @get_mapping("/class")
+ def class_method(cls) -> str:
+ return "ok"
+
+ @rest_controller
+ class Child(Parent):
+ pass
+
+ registrar = ControllerRegistrar()
+ ctx = context(Child)
+ assert {m.path for m in registrar.collect_route_metadata(ctx)} == {"/ordinary", "/static", "/class"}
+ assert len(registrar.collect_routes(ctx)) == 3
+ assert registrar.collect_websocket_routes(ctx) == []
+
+
+def test_shared_definitions_modes_aliases_recursion_collisions_and_determinism():
+ left = create_model("Duplicate", left=(str, ...), __module__="one")
+ right = create_model("Duplicate", right=(int, ...), __module__="two")
+ routes = [
+ route("/alias", return_type=Aliased, request_body_model=Aliased),
+ route("/left", return_type=left),
+ route("/right", return_type=right),
+ route("/tree", return_type=Tree),
+ route("/pets", return_type=list[Pet]),
+ ]
+ gen = OpenAPIGenerator("Test", "1")
+ spec = gen.generate(routes)
+ assert gen.generate(list(reversed(routes))) == spec
+ assert gen.generate(routes) == spec
+ assert_refs_resolve(spec)
+ op = spec["paths"]["/alias"]["post"]
+ inp = resolve(spec, op["requestBody"]["content"]["application/json"]["schema"])
+ out = resolve(spec, op["responses"]["200"]["content"]["application/json"]["schema"])
+ assert inp["properties"]["input"]["type"] == "integer"
+ assert out["properties"]["output"]["type"] == "string"
+ for path, field in (("/left", "left"), ("/right", "right")):
+ schema = resolve(spec, spec["paths"][path]["post"]["responses"]["200"]["content"]["application/json"]["schema"])
+ assert field in schema["properties"]
+
+
+def test_ids_are_unique_order_independent_and_resist_suffix_collisions():
+ routes = [route("/a", "same"), route("/b", "same"), route("/c", "same_post_b")]
+ mounts = [MountedRoute("/d", "POST", "same", ""), MountedRoute("/e", "POST", "same_post_d", "")]
+ gen = OpenAPIGenerator("Test", "1")
+ spec = gen.generate(routes, mounted_routes=mounts)
+ assert gen.generate(list(reversed(routes)), mounted_routes=list(reversed(mounts))) == spec
+ ids = [op["operationId"] for ops in spec["paths"].values() for op in ops.values()]
+ assert len(ids) == len(set(ids))
+ assert spec["paths"]["/c"]["post"]["operationId"] == "same_post_b"
+
+
+def test_duplicate_controller_operations_fail():
+ with pytest.raises(ValueError, match="(?i)duplicate.*POST.*/test"):
+ generate(route(), route(name="other"))
+
+
+@pytest.mark.parametrize("return_type", [JSONResponse, Request])
+def test_unsupported_inferred_response_falls_back(return_type):
+ assert generate(route(return_type=return_type))["paths"]["/test"]["post"]["responses"] == {
+ "200": {"description": "Successful response"}
+ }
+
+
+def test_manual_contract_preserves_dispatch_and_supports_both_orders(api):
+ async def manual(self, request: Request) -> JSONResponse:
+ data = await request.json()
+ return JSONResponse({"raw": data}, status_code=202, headers={"X-Job": "accepted"})
+
+ signature = inspect.signature(manual)
+ annotations = dict(manual.__annotations__)
+ decorator = api.openapi_operation(
+ operation_id="manual.accept",
+ summary="Accept input",
+ tags=["Manual"],
+ security=[],
+ request_body=api.OpenAPIRequestBody(content={"application/json": list[Pet]}, required=False),
+ responses={
+ 202: api.OpenAPIResponse(
+ "Accepted",
+ content={"application/json": dict[str, object]},
+ headers={"X-Job": api.OpenAPIHeader(str, description="Job state")},
+ )
+ },
+ replace_responses=True,
+ )
+ assert decorator(manual) is manual
+ assert inspect.signature(manual) == signature
+ assert manual.__annotations__ == annotations
+
+ @rest_controller
+ class Manual:
+ pass
+
+ Manual.handle = post_mapping("/manual")(manual)
+ acquisition = []
+ ctx = context(Manual)
+ ctx.get_bean = lambda cls: acquisition.append(cls) or cls()
+ registrar = ControllerRegistrar()
+ spec = generate(*registrar.collect_route_metadata(ctx))
+ routes = registrar.collect_routes(ctx)
+ assert acquisition == []
+ response = TestClient(Starlette(routes=routes)).post("/manual", json={"not": "a pet list"})
+ assert response.status_code == 202
+ assert response.content == b'{"raw":{"not":"a pet list"}}'
+ assert response.headers["x-job"] == "accepted"
+ assert acquisition == [Manual]
+ op = spec["paths"]["/manual"]["post"]
+ assert set(op["responses"]) == {"202"}
+ assert op["responses"]["202"]["headers"]["X-Job"]["schema"] == {"type": "string"}
+ assert op["security"] == []
+ assert op["operationId"] == "manual.accept"
+ assert not op["requestBody"]["required"]
+
+ @rest_controller
+ class Reverse:
+ @api.openapi_operation(operation_id="reverse")
+ @get_mapping("/reverse", name="mapping")
+ def handler(self) -> str:
+ return "ok"
+
+ assert (
+ generate(*registrar.collect_route_metadata(context(Reverse)))["paths"]["/reverse"]["get"]["operationId"]
+ == "reverse"
+ )
+
+
+def test_explicit_overrides_422_body_omission_and_empty_parameters(api):
+ operation = api.OpenAPIOperation(
+ request_body=None,
+ parameters=[],
+ tags=[],
+ summary="",
+ deprecated=False,
+ responses={422: api.OpenAPIResponse("Actual error", content={"application/problem+json": Cat})},
+ )
+ spec = generate(
+ route(
+ request_body_model=Dog,
+ operation=operation,
+ summary="inferred",
+ tag="tag",
+ deprecated=True,
+ parameters=[{"name": "q", "in": "query", "schema": {"type": "string"}}],
+ )
+ )
+ op = spec["paths"]["/test"]["post"]
+ assert "requestBody" not in op
+ assert "parameters" not in op
+ assert "tags" not in op
+ assert "summary" not in op
+ assert not op.get("deprecated", False)
+ assert set(op["responses"]) == {"200", "422"}
+ assert op["responses"]["422"]["description"] == "Actual error"
+ assert "application/problem+json" in op["responses"]["422"]["content"]
+
+
+def test_explicit_parameters_body_headers_modes_and_custom_generator(api):
+ class Custom(GenerateJsonSchema):
+ def generate_inner(self, schema):
+ result = super().generate_inner(schema)
+ if result.get("type") == "integer":
+ result["x-custom"] = True
+ return result
+
+ operation = api.OpenAPIOperation(
+ parameters=[
+ api.OpenAPIParameter("tenant", "header", TypeAdapter(UUID), required=False),
+ api.OpenAPIParameter("limit", "query", Annotated[int, Field(gt=0)], default=5),
+ ],
+ request_body=api.OpenAPIRequestBody(content={"application/json": TypeAdapter(Aliased)}),
+ responses={
+ 200: api.OpenAPIResponse(
+ "OK",
+ content={"application/json": TypeAdapter(Aliased)},
+ headers={"X-Value": api.OpenAPIHeader(Aliased)},
+ )
+ },
+ )
+ spec = generate(route(operation=operation), schema_generator=Custom)
+ op = spec["paths"]["/test"]["post"]
+ assert op["parameters"][0]["schema"]["format"] == "uuid"
+ assert op["parameters"][1]["schema"] == {"type": "integer", "exclusiveMinimum": 0, "default": 5, "x-custom": True}
+ req = resolve(spec, op["requestBody"]["content"]["application/json"]["schema"])
+ head = resolve(spec, op["responses"]["200"]["headers"]["X-Value"]["schema"])
+ assert req["properties"]["input"]["x-custom"] is True
+ assert head["properties"]["output"]["type"] == "string"
+ assert_refs_resolve(spec)
+
+
+def test_security_and_generator_injection(api):
+ gen = OpenAPIGenerator(
+ "Custom", "2", security_schemes={"token": {"type": "http", "scheme": "bearer"}}, security=[{"token": []}]
+ )
+ client = TestClient(create_app(openapi_generator=gen))
+ spec = client.get("/openapi.json").json()
+ assert spec["security"] == [{"token": []}]
+ assert spec["components"]["securitySchemes"]["token"]["scheme"] == "bearer"
+ assert spec["info"]["title"] == "Custom"
+
+
+def test_duplicate_explicit_ids_fail_and_implicit_id_yields(api):
+ explicit = api.OpenAPIOperation(operation_id="chosen")
+ with pytest.raises(ValueError, match="(?i)duplicate.*operation.*chosen"):
+ generate(route("/one", operation=explicit), route("/two", operation=explicit))
+ spec = generate(route("/one", operation=explicit), route("/two", "chosen"))
+ assert spec["paths"]["/one"]["post"]["operationId"] == "chosen"
+ assert spec["paths"]["/two"]["post"]["operationId"] != "chosen"
+
+
+@pytest.mark.parametrize("kind", ["body", "response", "parameter", "header"])
+def test_invalid_explicit_schema_fails_clearly(api, kind):
+ if kind == "body":
+ op = api.OpenAPIOperation(request_body=api.OpenAPIRequestBody(content={"application/json": Request}))
+ elif kind == "response":
+ op = api.OpenAPIOperation(responses={200: api.OpenAPIResponse("OK", content={"application/json": Request})})
+ elif kind == "parameter":
+ op = api.OpenAPIOperation(parameters=[api.OpenAPIParameter("q", "query", Request)])
+ else:
+ op = api.OpenAPIOperation(responses={200: api.OpenAPIResponse("OK", headers={"X": api.OpenAPIHeader(Request)})})
+ with pytest.raises(ValueError, match="(?i)schema.*POST /test"):
+ generate(route(operation=op))
+
+
+@pytest.mark.parametrize(
+ "kwargs",
+ [
+ {"responses": {99: "bad"}},
+ {"responses": {200: "bad"}},
+ {"responses": {"200": None}},
+ {"request_body": {}},
+ {"parameters": ["q"]},
+ {"operation_id": ""},
+ {"security": ["bad"]},
+ {"replace_responses": True, "responses": {}},
+ ],
+)
+def test_malformed_operation_metadata_fails(api, kwargs):
+ with pytest.raises((ValueError, TypeError)):
+ generate(route(operation=api.OpenAPIOperation(**kwargs)))
+
+
+def test_validation_schema_names_cannot_overwrite_user_models():
+ model = create_model("HTTPValidationError", custom=(str, ...))
+ spec = generate(route(return_type=model, request_body_model=Cat))
+ op = spec["paths"]["/test"]["post"]
+ success = resolve(spec, op["responses"]["200"]["content"]["application/json"]["schema"])
+ failure = resolve(spec, op["responses"]["422"]["content"]["application/json"]["schema"])
+ assert "custom" in success["properties"]
+ assert "detail" in failure["properties"]
+ assert_refs_resolve(spec)
+
+
+def test_offline_public_import_and_generation_do_not_import_starlette(api):
+ source = """
+import sys
+class BlockStarlette:
+ def find_spec(self, fullname, path=None, target=None):
+ if fullname == "starlette" or fullname.startswith("starlette."):
+ raise AssertionError("offline contract imported Starlette: " + fullname)
+sys.meta_path.insert(0, BlockStarlette())
+from pyfly.web import RouteMetadata, OpenAPIOperation, OpenAPIResponse
+from pyfly.web.openapi import OpenAPIGenerator
+operation = OpenAPIOperation(responses={200: OpenAPIResponse("OK", content={"application/json": int})})
+route = RouteMetadata("/offline", "GET", 200, None, "offline", operation=operation)
+spec = OpenAPIGenerator("Offline", "1").generate([route])
+response = spec["paths"]["/offline"]["get"]["responses"]["200"]
+assert response["content"]["application/json"]["schema"]["type"] == "integer"
+"""
+ result = subprocess.run([sys.executable, "-c", source], capture_output=True, text=True)
+ assert result.returncode == 0, result.stderr
+
+
+@pytest.mark.parametrize(
+ "factory,kwargs",
+ [
+ ("OpenAPIRequestBody", {"content": {"application/json": str}, "required": "yes"}),
+ ("OpenAPIRequestBody", {"content": {"application/json": str}, "description": 42}),
+ ("OpenAPIParameter", {"name": "q", "location": "query", "schema": str, "required": "yes"}),
+ ("OpenAPIHeader", {"schema": str, "description": 42}),
+ ("OpenAPIHeader", {"schema": str, "required": "yes"}),
+ ],
+)
+def test_malformed_contract_values_fail(api, factory, kwargs):
+ with pytest.raises((TypeError, ValueError)):
+ getattr(api, factory)(**kwargs)
+
+
+def test_unresolved_explicit_type_has_route_context(api):
+ operation = api.OpenAPIOperation(
+ responses={200: api.OpenAPIResponse("OK", content={"application/json": "MissingType"})}
+ )
+ with pytest.raises(ValueError, match="(?i)schema.*POST /test"):
+ generate(route(operation=operation))
+
+
+def test_explicit_422_replaces_inferred_validation(api):
+ operation = api.OpenAPIOperation(responses={422: api.OpenAPIResponse("Bad input", content={"text/plain": str})})
+ spec = generate(route(request_body_model=Cat, operation=operation))
+ assert spec["paths"]["/test"]["post"]["responses"]["422"] == {
+ "description": "Bad input",
+ "content": {"text/plain": {"schema": {"type": "string"}}},
+ }
+ assert "HTTPValidationError" not in spec["components"]["schemas"]
+
+
+def test_same_module_model_names_and_recursive_schema_are_distinct():
+ left = create_model("Twin", left=(str, ...))
+ right = create_model("Twin", right=(int, ...))
+ spec = generate(
+ route("/left", return_type=left), route("/right", return_type=right), route("/tree", return_type=Tree)
+ )
+ for path, field in (("/left", "left"), ("/right", "right"), ("/tree", "children")):
+ schema = resolve(spec, spec["paths"][path]["post"]["responses"]["200"]["content"]["application/json"]["schema"])
+ assert field in schema["properties"]
+ assert_refs_resolve(spec)
+
+
+def test_optional_parameter_without_default_is_not_required():
+ @rest_controller
+ class OptionalController:
+ @get_mapping("/optional")
+ def handle(self, value: QueryParam[int | None]) -> str:
+ return "ok"
+
+ spec = generate(*ControllerRegistrar().collect_route_metadata(context(OptionalController)))
+ param = spec["paths"]["/optional"]["get"]["parameters"][0]
+ assert param["required"] is False
+ assert "default" not in param["schema"]
+ assert param["schema"]["anyOf"] == [{"type": "integer"}, {"type": "null"}]
+
+
+def test_security_document_mutation_does_not_leak_to_next_generation():
+ generator = OpenAPIGenerator("Test", "1", security=[{"token": ["read"]}])
+ spec = generator.generate()
+ spec["security"][0]["token"].append("write")
+ assert generator.generate()["security"] == [{"token": ["read"]}]
+
+
+def test_non_sequence_tags_are_rejected(api):
+ with pytest.raises(TypeError, match="tags"):
+ api.OpenAPIOperation(tags={"tag": "wrong shape"})
+
+
+def test_custom_chain_generator_applies_to_request_and_response(api):
+ from pydantic_core import core_schema
+
+ class Chained:
+ @classmethod
+ def __get_pydantic_core_schema__(cls, source, handler):
+ return core_schema.chain_schema(
+ [core_schema.str_schema(min_length=3), core_schema.str_schema(max_length=10)]
+ )
+
+ class ChainGenerator(GenerateJsonSchema):
+ def chain_schema(self, schema):
+ return {"allOf": [self.generate_inner(step) for step in schema["steps"]]}
+
+ operation = api.OpenAPIOperation(
+ request_body=api.OpenAPIRequestBody(content={"application/json": Chained}),
+ responses={200: api.OpenAPIResponse("OK", content={"application/json": Chained})},
+ )
+ spec = generate(route(operation=operation), schema_generator=ChainGenerator)
+ op = spec["paths"]["/test"]["post"]
+ expected = {"allOf": [{"type": "string", "minLength": 3}, {"type": "string", "maxLength": 10}]}
+ assert op["requestBody"]["content"]["application/json"]["schema"] == expected
+ assert op["responses"]["200"]["content"]["application/json"]["schema"] == expected
+
+
+def test_nullable_body_default_does_not_invent_optional_runtime_binding():
+ @rest_controller
+ class NullableBodyController:
+ @post_mapping("/nullable")
+ def handle(self, body: Body[Cat | None] = None) -> str:
+ return "ok"
+
+ spec = generate(*ControllerRegistrar().collect_route_metadata(context(NullableBodyController)))
+ body = spec["paths"]["/nullable"]["post"]["requestBody"]
+ assert body["required"] is True
+ assert {"type": "null"} in body["content"]["application/json"]["schema"]["anyOf"]
+
+
+@pytest.mark.parametrize(
+ "binding,location,required",
+ [(QueryParam, "query", False), (Header, "header", False), (Cookie, "cookie", False), (PathVar, "path", True)],
+)
+async def test_none_only_parameters_match_omission_behavior(binding, location, required):
+ from pyfly.web.adapters.starlette.resolver import ParameterResolver
+
+ path = "/none/{value}" if location == "path" else "/none"
+
+ @rest_controller
+ class NoneOnlyController:
+ @get_mapping(path)
+ def handle(self, value: binding[type(None)]) -> str:
+ return "ok"
+
+ spec = generate(*ControllerRegistrar().collect_route_metadata(context(NoneOnlyController)))
+ param = spec["paths"][path]["get"]["parameters"][0]
+ assert param["in"] == location
+ assert param["schema"] == {"type": "null"}
+ assert param["required"] is required
+
+ resolver = ParameterResolver(NoneOnlyController.handle)
+ request = Request({"type": "http", "headers": [], "query_string": b"", "path_params": {}})
+ if required:
+ with pytest.raises(ValueError, match="Missing path variable"):
+ await resolver.resolve(request)
+ else:
+ assert await resolver.resolve(request) == {"value": None}
diff --git a/tests/web/test_openapi_mounted_routes.py b/tests/web/test_openapi_mounted_routes.py
index 1fa86bb5..37703968 100644
--- a/tests/web/test_openapi_mounted_routes.py
+++ b/tests/web/test_openapi_mounted_routes.py
@@ -182,8 +182,8 @@ def test_path_parameters_are_declared_on_the_operation(self) -> None:
def test_operation_ids_are_unique_across_the_document(self) -> None:
"""Six sub-apps each with a ``health`` endpoint produced six ``operationId: health`` —
- an OpenAPI document that does not validate. The first keeps the bare name; the others
- are qualified by method and path, deterministically, so a diff of the document is stable."""
+ an OpenAPI document that does not validate. The first in sorted path/method order
+ keeps the bare name; the others are qualified by method and path, deterministically."""
from pyfly.web.adapters.starlette.controller import RouteMetadata
meta = RouteMetadata(path="/status", http_method="GET", status_code=200, handler=ping, handler_name="health")
@@ -213,8 +213,8 @@ def test_operation_ids_are_unique_across_the_document(self) -> None:
spec["paths"]["/api/telegram/{botId}/health"]["get"]["operationId"]
== "health_get_api_telegram_botId_health"
)
- assert spec["paths"]["/api/events"]["post"]["operationId"] == "events"
- assert spec["paths"]["/api/events"]["get"]["operationId"] == "events_get_api_events"
+ assert spec["paths"]["/api/events"]["post"]["operationId"] == "events_post_api_events"
+ assert spec["paths"]["/api/events"]["get"]["operationId"] == "events"
def test_a_controller_operation_is_never_overwritten_by_a_mounted_one(self) -> None:
from pyfly.web.adapters.starlette.controller import RouteMetadata
diff --git a/uv.lock b/uv.lock
index 5ce573e8..3b666b92 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2337,7 +2337,7 @@ wheels = [
[[package]]
name = "pyfly"
-version = "26.9.11"
+version = "26.9.12"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },
diff --git a/web/index.html b/web/index.html
index e0b3459b..ca72156f 100644
--- a/web/index.html
+++ b/web/index.html
@@ -91,7 +91,7 @@
Apache 2.0
mypy strict
async-first
- v26.09.11
+ v26.09.12