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 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.09.11 + Version: 26.09.12 Type Checked: mypy strict Code Style: Ruff Async First @@ -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