From 88f733cc7d0a29e23a9c4578c08aec988f9aa1d2 Mon Sep 17 00:00:00 2001 From: Mateo Li Date: Wed, 30 Sep 2026 13:14:21 +0300 Subject: [PATCH 01/10] Add configurable OpenAPI security schemes and version documentation --- README.md | 101 ++ microbootstrap/__init__.py | 24 + microbootstrap/bootstrappers/fastapi.py | 168 ++++ microbootstrap/bootstrappers/faststream.py | 6 +- microbootstrap/bootstrappers/litestar.py | 232 +++++ .../instruments/openapi_security_schemes.py | 127 +++ .../instruments/openapi_version_docs.py | 156 ++++ .../instruments/swagger_instrument.py | 22 + pyproject.toml | 8 +- tests/bootstrappers/test_fastapi.py | 50 + tests/bootstrappers/test_faststream.py | 2 - .../test_litestar_opentelemetry.py | 77 +- .../test_openapi_version_docs.py | 869 ++++++++++++++++++ tests/conftest.py | 74 +- .../test_openapi_security_schemes.py | 167 ++++ tests/instruments/test_opentelemetry.py | 67 +- tests/instruments/test_pyroscope.py | 41 +- tests/instruments/test_swagger.py | 131 ++- 18 files changed, 2298 insertions(+), 24 deletions(-) create mode 100644 microbootstrap/instruments/openapi_security_schemes.py create mode 100644 microbootstrap/instruments/openapi_version_docs.py create mode 100644 tests/bootstrappers/test_openapi_version_docs.py create mode 100644 tests/instruments/test_openapi_security_schemes.py diff --git a/README.md b/README.md index edc91f7..d3e1524 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,20 @@ Also, you can specify extras during installation for concrete framework: - `faststream` (ASGI app) - `fastmcp` +The `fastapi`, `litestar`, and `faststream` bounds are support-policy bounds based on the evidence below, not +mathematical minimum versions or certification of every version in each declared range, every dependency combination, +or every supported Python version. + +A full combined selected-framework-floors suite passed 308 tests on Python 3.12.7 with FastAPI 0.110.1, +prometheus-fastapi-instrumentator 7.1.0, Litestar 2.21.1, and FastStream 0.6.7. A separate fresh frozen installation +using uv 0.10 passed 308 tests, Ruff check, Ruff format check, and mypy with an exact resolved graph including FastAPI +0.141.1, prometheus-fastapi-instrumentator 8.1.0, Litestar 2.24.0, and FastStream 0.6.7. The ignored lock file is +local validation evidence and is not included in this PR. These runs exclude the brokerless AsyncAPI case and do not +certify future releases, platform variants, multi-broker configurations, external transports, or OTLP delivery. + +An application without a broker is not required to deliver AsyncAPI documentation and is not part of the OpenAPI +delivery contract. + Also we have `granian` extra that is requires for `create_granian_server`. For uv: @@ -594,6 +608,93 @@ Parameter descriptions: - `swagger_offline_docs` - A boolean value that, when set to True, allows the Swagger JS bundles to be accessed offline. This is because the service starts to host via static. - `swagger_extra_params` - Additional parameters to pass into the OpenAPI configuration. +#### Optional security schemes and API-version documentation + +Both OpenAPI additions are opt-in. `SwaggerConfig.security_schemes` adds reusable definitions to +`components.securitySchemes`; it does not add global or operation-level security requirements. +`OpenApiVersionDocsConfig` documents a media-type convention only; it does not negotiate requests, add an `Accept` +parameter, change response media types, or select a version in Swagger UI. + +```python +from microbootstrap import ( + OpenApiApiKeySecurityScheme as ApiKeySecurityScheme, + OpenApiHttpSecurityScheme, + OpenApiOperationSelector, + OpenApiOperationVersionOverride, + OpenApiOAuth2SecurityScheme, + OpenApiOAuthFlow, + OpenApiOAuthFlows, + OpenApiOpenIdConnectSecurityScheme, + OpenApiVersionDocsConfig, + SwaggerConfig, +) + +version_docs = OpenApiVersionDocsConfig( + enabled=True, + vendor_media_type="application/vnd.example+json", + supported_versions=("1.0",), + suppressed_operations=(OpenApiOperationSelector(path="/internal/widgets", method="get"),), + operation_versions=( + OpenApiOperationVersionOverride(path="/widgets", method="post", supported_versions=("2.0",)), + ), +) + +application = ( + LitestarBootstrapper(settings) + .configure_instrument( + SwaggerConfig( + security_schemes={ + "serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer", bearer_format="JWT"), + "apiKey": ApiKeySecurityScheme(name="X-API-Key", location="header"), + "oauth": OpenApiOAuth2SecurityScheme( + flows=OpenApiOAuthFlows( + client_credentials=OpenApiOAuthFlow(token_url="/oauth/token", scopes={"read": "Read data"}) + ) + ), + "oidc": OpenApiOpenIdConnectSecurityScheme( + open_id_connect_url="/.well-known/openid-configuration" + ), + }, + openapi_version_docs=version_docs, + ) + ) + .bootstrap() +) +``` + +The supported typed definitions are HTTP, API key, OAuth 2.0, and OpenID Connect. Python field names and OpenAPI +aliases are accepted; serialized schemas use canonical OpenAPI names such as `bearerFormat`, `in`, `tokenUrl`, and +`openIdConnectUrl`. OAuth flow and OpenID Connect URLs may be relative, but must be non-empty and contain no whitespace +or control characters. OAuth 2.0 configuration must declare at least one flow. A matching service-owned scheme is retained, while a same-named +conflicting definition raises `ValueError`. + +Each non-suppressed documented operation receives an `x-accept-versioning` extension and matching text appended to its +existing description. The extension is: + +```json +{ + "header": "Accept", + "mediaType": "application/vnd.example+json", + "parameter": "version", + "supportedVersions": ["1.0"] +} +``` + +`operation_versions` replaces the project version list for its exact path and lower-case method pair. Suppressed pairs +remain untouched. A conflicting service-owned `x-accept-versioning` extension raises `ValueError`; an identical one is +idempotent. Configure before the first schema or documentation request. + +When enabled, `vendor_media_type` is required explicitly. It must match +`application/vnd.+json`, where `` is one or more characters from +`!#$%&'*+-.^_|~0-9A-Za-z`. Backticks are intentionally excluded from the HTTP token set because values are rendered in +Markdown code spans. Each `supported_versions` value uses the same non-empty grammar and is not constrained to a +numeric or semantic-version format. Whitespace, control characters, commas, semicolons, and values outside that grammar +are rejected; values are never trimmed or escaped. + +For Litestar, the bootstrapper updates the canonical OpenAPI model; for FastAPI, it composes the public `app.openapi` +callable. Custom components, renderers, generators, schema caching, and errors are retained. Exporters should use that +same canonical schema. + #### FastStream AsyncAPI documentation AsyncAPI documentation is available by default under `/asyncapi` route. You can change that by setting `asyncapi_path`: diff --git a/microbootstrap/__init__.py b/microbootstrap/__init__.py index 784b5e2..54cf217 100644 --- a/microbootstrap/__init__.py +++ b/microbootstrap/__init__.py @@ -1,6 +1,20 @@ from microbootstrap.instruments.cors_instrument import CorsConfig from microbootstrap.instruments.health_checks_instrument import HealthChecksConfig from microbootstrap.instruments.logging_instrument import LoggingConfig +from microbootstrap.instruments.openapi_security_schemes import ( + OpenApiApiKeySecurityScheme, + OpenApiHttpSecurityScheme, + OpenApiOAuth2SecurityScheme, + OpenApiOAuthFlow, + OpenApiOAuthFlows, + OpenApiOpenIdConnectSecurityScheme, + OpenApiSecurityScheme, +) +from microbootstrap.instruments.openapi_version_docs import ( + OpenApiOperationSelector, + OpenApiOperationVersionOverride, + OpenApiVersionDocsConfig, +) from microbootstrap.instruments.opentelemetry_instrument import ( FastStreamOpentelemetryConfig, FastStreamTelemetryMiddlewareProtocol, @@ -42,6 +56,16 @@ "LitestarPrometheusConfig", "LitestarSettings", "LoggingConfig", + "OpenApiApiKeySecurityScheme", + "OpenApiHttpSecurityScheme", + "OpenApiOAuth2SecurityScheme", + "OpenApiOAuthFlow", + "OpenApiOAuthFlows", + "OpenApiOpenIdConnectSecurityScheme", + "OpenApiOperationSelector", + "OpenApiOperationVersionOverride", + "OpenApiSecurityScheme", + "OpenApiVersionDocsConfig", "OpentelemetryConfig", "PyroscopeConfig", "SentryConfig", diff --git a/microbootstrap/bootstrappers/fastapi.py b/microbootstrap/bootstrappers/fastapi.py index 628a953..c5538b3 100644 --- a/microbootstrap/bootstrappers/fastapi.py +++ b/microbootstrap/bootstrappers/fastapi.py @@ -12,6 +12,15 @@ from microbootstrap.instruments.cors_instrument import CorsInstrument from microbootstrap.instruments.health_checks_instrument import HealthChecksInstrument, HealthCheckTypedDict from microbootstrap.instruments.logging_instrument import LoggingInstrument +from microbootstrap.instruments.openapi_security_schemes import OpenApiSecurityScheme, serialize_security_schemes +from microbootstrap.instruments.openapi_version_docs import ( + SUPPORTED_HTTP_METHODS, + OpenApiVersionDocsConfig, + append_version_documentation, + build_accept_versioning_extension, + get_supported_versions, + is_operation_suppressed, +) from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument from microbootstrap.instruments.prometheus_instrument import FastApiPrometheusConfig, PrometheusInstrument from microbootstrap.instruments.pyroscope_instrument import PyroscopeInstrument @@ -67,9 +76,168 @@ def bootstrap_before(self) -> dict[str, typing.Any]: def bootstrap_after(self, application: ApplicationT) -> ApplicationT: if self.instrument_config.swagger_offline_docs: enable_offline_docs(application, static_files_handler=self.instrument_config.service_static_path) + version_docs_config: typing.Final = self.instrument_config.openapi_version_docs + security_schemes: typing.Final = self.instrument_config.security_schemes + version_docs_enabled: typing.Final = version_docs_config is not None and version_docs_config.enabled + if not version_docs_enabled and not security_schemes: + return application + + original_openapi: typing.Final = application.openapi + + def documented_openapi() -> dict[str, typing.Any]: + openapi_schema: typing.Final = original_openapi() + expected_schemes: dict[str, dict[str, typing.Any]] | None = None + if security_schemes: + expected_schemes = prepare_security_schemes(openapi_schema, security_schemes) + version_documentation: list[tuple[dict[str, typing.Any], dict[str, str | list[str]], str]] = [] + if version_docs_enabled: + assert version_docs_config is not None # noqa: S101 - checked above. + version_documentation = prepare_version_documentation(openapi_schema, version_docs_config) + if expected_schemes is not None: + apply_security_schemes(openapi_schema, expected_schemes) + apply_version_documentation(version_documentation) + return openapi_schema + + application.openapi = documented_openapi # type: ignore[method-assign] # FastAPI's public custom OpenAPI hook. return application +def add_version_documentation( + openapi_schema: dict[str, typing.Any], + configuration: OpenApiVersionDocsConfig, +) -> None: + apply_version_documentation(prepare_version_documentation(openapi_schema, configuration)) + + +def prepare_version_documentation( + openapi_schema: dict[str, typing.Any], + configuration: OpenApiVersionDocsConfig, +) -> list[tuple[dict[str, typing.Any], dict[str, str | list[str]], str]]: + updates: list[tuple[dict[str, typing.Any], dict[str, str | list[str]], str]] = [] + paths = openapi_schema.get("paths") + if not isinstance(paths, dict): + return updates + for path, path_item in paths.items(): + if not isinstance(path, str) or not isinstance(path_item, dict): + continue + for method, operation in path_item.items(): + update = prepare_document_operation(configuration, path, method, operation) + if update is not None: + updates.append(update) + return updates + + +def apply_version_documentation( + updates: typing.Iterable[tuple[dict[str, typing.Any], dict[str, str | list[str]], str]], +) -> None: + for operation, extension, description in updates: + operation["x-accept-versioning"] = extension + operation["description"] = description + + +def document_operation( + configuration: OpenApiVersionDocsConfig, + path: str, + method: object, + operation: object, +) -> None: + update = prepare_document_operation(configuration, path, method, operation) + if update is not None: + apply_version_documentation((update,)) + + +def prepare_document_operation( + configuration: OpenApiVersionDocsConfig, + path: str, + method: object, + operation: object, +) -> tuple[dict[str, typing.Any], dict[str, str | list[str]], str] | None: + if ( + not isinstance(method, str) + or not isinstance(operation, dict) + or method not in SUPPORTED_HTTP_METHODS + or is_operation_suppressed(configuration, path, method) + ): + return None + supported_versions = get_supported_versions(configuration, path, method) + extension = build_accept_versioning_extension(configuration, supported_versions) + existing_extension = operation.get("x-accept-versioning") + if existing_extension is not None and existing_extension != extension: + message = "OpenAPI operation x-accept-versioning conflicts with configured Accept version documentation." + raise ValueError(message) + if "x-accept-versioning" in operation and existing_extension is None: + message = "OpenAPI operation x-accept-versioning conflicts with configured Accept version documentation." + raise ValueError(message) + description = operation.get("description") + if description is not None and not isinstance(description, str): + message = f"OpenAPI operation {method.upper()} {path} has a non-string description." + raise ValueError(message) + return operation, extension, append_version_documentation(description, configuration, supported_versions) + + +def add_security_schemes( + openapi_schema: dict[str, typing.Any], + configured_schemes: typing.Mapping[str, OpenApiSecurityScheme], +) -> None: + apply_security_schemes(openapi_schema, prepare_security_schemes(openapi_schema, configured_schemes)) + + +def prepare_security_schemes( + openapi_schema: dict[str, typing.Any], + configured_schemes: typing.Mapping[str, OpenApiSecurityScheme], +) -> dict[str, dict[str, typing.Any]]: + expected_schemes: typing.Final = serialize_security_schemes(configured_schemes) + components = openapi_schema.get("components") + if components is None: + return expected_schemes + if not isinstance(components, dict): + message = "OpenAPI components must be a dictionary to configure security schemes." + raise TypeError(message) + + security_schemes = components.get("securitySchemes") + if security_schemes is None: + return expected_schemes + if not isinstance(security_schemes, dict): + message = "OpenAPI components.securitySchemes must be a dictionary to configure security schemes." + raise TypeError(message) + + for scheme_name, expected_scheme in expected_schemes.items(): + if scheme_name in security_schemes and security_schemes[scheme_name] != expected_scheme: + message = f"OpenAPI security scheme '{scheme_name}' conflicts with the configured security scheme." + raise ValueError(message) + return expected_schemes + + +def apply_security_schemes( + openapi_schema: dict[str, typing.Any], + expected_schemes: dict[str, dict[str, typing.Any]], +) -> None: + components = openapi_schema.get("components") + if components is None: + openapi_schema["components"] = {"securitySchemes": expected_schemes} + return + assert isinstance(components, dict) # noqa: S101 - validated before application. + security_schemes = components.get("securitySchemes") + if security_schemes is None: + components["securitySchemes"] = expected_schemes + return + assert isinstance(security_schemes, dict) # noqa: S101 - validated before application. + security_schemes.update({name: scheme for name, scheme in expected_schemes.items() if name not in security_schemes}) + + +def add_accept_versioning_extension( + operation: dict[str, typing.Any], + expected_extension: dict[str, str | list[str]], +) -> None: + extension_name: typing.Final = "x-accept-versioning" + if extension_name not in operation: + operation[extension_name] = expected_extension + return + if operation[extension_name] != expected_extension: + message = f"OpenAPI operation {extension_name} conflicts with configured Accept version documentation." + raise ValueError(message) + + @FastApiBootstrapper.use_instrument() class FastApiCorsInstrument(CorsInstrument): def bootstrap_after(self, application: ApplicationT) -> ApplicationT: diff --git a/microbootstrap/bootstrappers/faststream.py b/microbootstrap/bootstrappers/faststream.py index ecef4c9..9b533b9 100644 --- a/microbootstrap/bootstrappers/faststream.py +++ b/microbootstrap/bootstrappers/faststream.py @@ -57,7 +57,11 @@ def _isolate_faststream_subscribers(application: AsgiFastStream) -> None: class KwargsAsgiFastStream(AsgiFastStream): def __init__(self, **kwargs: typing.Any) -> None: # noqa: ANN401 # `broker` argument is positional-only - super().__init__(kwargs.pop("broker", None), **kwargs) + broker = kwargs.pop("broker", None) + if broker is None: + super().__init__(**kwargs) + else: + super().__init__(broker, **kwargs) class FastStreamBootstrapper(ApplicationBootstrapper[FastStreamSettings, AsgiFastStream, FastStreamConfig]): diff --git a/microbootstrap/bootstrappers/litestar.py b/microbootstrap/bootstrappers/litestar.py index bf827a8..4211faf 100644 --- a/microbootstrap/bootstrappers/litestar.py +++ b/microbootstrap/bootstrappers/litestar.py @@ -1,4 +1,5 @@ from __future__ import annotations +import dataclasses import typing import litestar @@ -25,6 +26,22 @@ HealthCheckTypedDict, ) from microbootstrap.instruments.logging_instrument import LoggingInstrument +from microbootstrap.instruments.openapi_security_schemes import ( + OpenApiApiKeySecurityScheme, + OpenApiHttpSecurityScheme, + OpenApiOAuth2SecurityScheme, + OpenApiOAuthFlow, + OpenApiOAuthFlows, + OpenApiOpenIdConnectSecurityScheme, + OpenApiSecurityScheme, +) +from microbootstrap.instruments.openapi_version_docs import ( + OpenApiVersionDocsConfig, + append_version_documentation, + build_accept_versioning_extension, + get_supported_versions, + is_operation_suppressed, +) from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument from microbootstrap.instruments.prometheus_instrument import ( LitestarPrometheusConfig, @@ -37,6 +54,9 @@ from microbootstrap.settings import LitestarSettings +ApplicationT = typing.TypeVar("ApplicationT", bound=litestar.Litestar) + + if typing.TYPE_CHECKING: from litestar.contrib.opentelemetry import OpenTelemetryConfig from litestar.types import ASGIApp, Scope @@ -102,6 +122,218 @@ def bootstrap_before(self) -> dict[str, typing.Any]: ] return bootstrap_result + def bootstrap_after(self, application: ApplicationT) -> ApplicationT: + version_docs_config: typing.Final = self.instrument_config.openapi_version_docs + security_schemes: typing.Final = self.instrument_config.security_schemes + if ( + (version_docs_config is None or not version_docs_config.enabled) and not security_schemes + ) or application.openapi_schema is None: + return application + expected_schemes: dict[str, openapi.spec.SecurityScheme] | None = None + if security_schemes: + expected_schemes = prepare_security_schemes(application.openapi_schema, security_schemes) + version_documentation: list[tuple[openapi.spec.PathItem, str, openapi.spec.Operation]] = [] + if version_docs_config is not None and version_docs_config.enabled: + version_documentation = prepare_version_documentation(application.openapi_schema, version_docs_config) + if expected_schemes is not None: + apply_security_schemes(application.openapi_schema, expected_schemes) + apply_version_documentation(version_documentation) + return application + + +def add_security_schemes( + openapi_schema: openapi.spec.OpenAPI, + configured_schemes: typing.Mapping[str, OpenApiSecurityScheme], +) -> None: + apply_security_schemes(openapi_schema, prepare_security_schemes(openapi_schema, configured_schemes)) + + +def prepare_security_schemes( + openapi_schema: openapi.spec.OpenAPI, + configured_schemes: typing.Mapping[str, OpenApiSecurityScheme], +) -> dict[str, openapi.spec.SecurityScheme]: + expected_schemes: typing.Final = { + scheme_name: build_litestar_security_scheme(security_scheme) + for scheme_name, security_scheme in configured_schemes.items() + } + security_schemes = openapi_schema.components.security_schemes + if security_schemes is None: + return expected_schemes + + for scheme_name, expected_scheme in expected_schemes.items(): + existing_scheme = security_schemes.get(scheme_name) + if existing_scheme is not None and ( + not isinstance(existing_scheme, openapi.spec.SecurityScheme) + or existing_scheme.to_schema() != expected_scheme.to_schema() + ): + message = f"OpenAPI security scheme '{scheme_name}' conflicts with the configured security scheme." + raise ValueError(message) + return expected_schemes + + +def apply_security_schemes( + openapi_schema: openapi.spec.OpenAPI, + expected_schemes: dict[str, openapi.spec.SecurityScheme], +) -> None: + security_schemes = openapi_schema.components.security_schemes + if security_schemes is None: + openapi_schema.components.security_schemes = typing.cast( + "dict[str, openapi.spec.SecurityScheme | openapi.spec.Reference]", + expected_schemes, + ) + return + security_schemes.update({name: scheme for name, scheme in expected_schemes.items() if name not in security_schemes}) + + +def prepare_version_documentation( + openapi_schema: openapi.spec.OpenAPI, + configuration: OpenApiVersionDocsConfig, +) -> list[tuple[openapi.spec.PathItem, str, openapi.spec.Operation]]: + updates: list[tuple[openapi.spec.PathItem, str, openapi.spec.Operation]] = [] + if openapi_schema.paths is None: + return updates + for path, path_item in openapi_schema.paths.items(): + if not isinstance(path_item, openapi.spec.PathItem): + continue + for method in ("delete", "get", "head", "options", "patch", "post", "put", "trace"): + operation = getattr(path_item, method) + if operation is None or is_operation_suppressed(configuration, path, method): + continue + if operation.description is not None and not isinstance(operation.description, str): + message = f"OpenAPI operation {method.upper()} {path} has a non-string description." + raise ValueError(message) + supported_versions = get_supported_versions(configuration, path, method) + extension = build_accept_versioning_extension(configuration, supported_versions) + description = append_version_documentation(operation.description, configuration, supported_versions) + documented_operation = add_accept_versioning_extension(operation, extension) + if documented_operation is operation: + if description == operation.description: + continue + documented_operation = copy_operation(operation) + object.__setattr__(documented_operation, "description", description) + updates.append((path_item, method, documented_operation)) + return updates + + +def apply_version_documentation( + updates: typing.Iterable[tuple[openapi.spec.PathItem, str, openapi.spec.Operation]], +) -> None: + for path_item, method, operation in updates: + setattr(path_item, method, operation) + + +def build_litestar_security_scheme(security_scheme: OpenApiSecurityScheme) -> openapi.spec.SecurityScheme: + if isinstance(security_scheme, OpenApiHttpSecurityScheme): + return openapi.spec.SecurityScheme( + type=security_scheme.type, + scheme=security_scheme.scheme, + bearer_format=security_scheme.bearer_format, + description=security_scheme.description, + ) + if isinstance(security_scheme, OpenApiApiKeySecurityScheme): + return openapi.spec.SecurityScheme( + type=security_scheme.type, + name=security_scheme.name, + security_scheme_in=security_scheme.location, + description=security_scheme.description, + ) + if isinstance(security_scheme, OpenApiOAuth2SecurityScheme): + return openapi.spec.SecurityScheme( + type=security_scheme.type, + flows=build_litestar_oauth_flows(security_scheme.flows), + description=security_scheme.description, + ) + if isinstance(security_scheme, OpenApiOpenIdConnectSecurityScheme): + return openapi.spec.SecurityScheme( + type=security_scheme.type, + open_id_connect_url=security_scheme.open_id_connect_url, + description=security_scheme.description, + ) + raise AssertionError("Unsupported OpenAPI security scheme.") + + +def build_litestar_oauth_flows(oauth_flows: OpenApiOAuthFlows) -> openapi.spec.OAuthFlows: + flow_arguments: typing.Final[dict[str, typing.Any]] = { + "implicit": build_litestar_oauth_flow(oauth_flows.implicit), + "password": build_litestar_oauth_flow(oauth_flows.resource_owner), + "client_credentials": build_litestar_oauth_flow(oauth_flows.client_credentials), + "authorization_code": build_litestar_oauth_flow(oauth_flows.authorization_code), + } + return openapi.spec.OAuthFlows(**flow_arguments) + + +def build_litestar_oauth_flow(oauth_flow: OpenApiOAuthFlow | None) -> openapi.spec.OAuthFlow | None: + if oauth_flow is None: + return None + return openapi.spec.OAuthFlow( + authorization_url=oauth_flow.authorization_url, + token_url=oauth_flow.token_url, + refresh_url=oauth_flow.refresh_url, + scopes=oauth_flow.scopes, + ) + + +def add_accept_versioning_extension( + operation: openapi.spec.Operation, + expected_extension: dict[str, str | list[str]], +) -> openapi.spec.Operation: + extension_name: typing.Final = "x-accept-versioning" + for field in dataclasses.fields(operation): + if field.metadata.get("alias") != extension_name: + continue + existing_extension = getattr(operation, field.name) + if existing_extension is None: + versioned_operation = copy_operation(operation) + object.__setattr__(versioned_operation, field.name, expected_extension) + return versioned_operation + if existing_extension != expected_extension: + message = f"OpenAPI operation {extension_name} conflicts with configured Accept version documentation." + raise ValueError(message) + return operation + + operation_type = typing.cast( + "type[openapi.spec.Operation]", + dataclasses.make_dataclass( + cls_name=f"{type(operation).__name__}WithAcceptVersioning", + fields=[ + ( + "accept_versioning", + dict[str, str | list[str]] | None, + dataclasses.field(default=None, metadata={"alias": extension_name}), + ) + ], + bases=(type(operation),), + ), + ) + versioned_operation = copy_operation(operation, operation_type) + object.__setattr__(versioned_operation, "accept_versioning", expected_extension) + return versioned_operation + + +def copy_operation( + operation: openapi.spec.Operation, + operation_type: type[openapi.spec.Operation] | None = None, +) -> openapi.spec.Operation: + copied_operation = object.__new__(operation_type or type(operation)) + copy_instance_state(operation, copied_operation) + return copied_operation + + +def copy_instance_state(source: object, target: object) -> None: + source_dict = getattr(source, "__dict__", None) + target_dict = getattr(target, "__dict__", None) + if isinstance(source_dict, dict) and isinstance(target_dict, dict): + target_dict.update(source_dict) + + for source_class in type(source).__mro__: + slot_names = source_class.__dict__.get("__slots__", ()) + if isinstance(slot_names, str): + slot_names = (slot_names,) + for slot_name in slot_names: + if slot_name in {"__dict__", "__weakref__"} or not hasattr(source, slot_name): + continue + object.__setattr__(target, slot_name, getattr(source, slot_name)) + @LitestarBootstrapper.use_instrument() class LitestarCorsInstrument(CorsInstrument): diff --git a/microbootstrap/instruments/openapi_security_schemes.py b/microbootstrap/instruments/openapi_security_schemes.py new file mode 100644 index 0000000..5820c9a --- /dev/null +++ b/microbootstrap/instruments/openapi_security_schemes.py @@ -0,0 +1,127 @@ +from __future__ import annotations +import typing +import unicodedata + +import pydantic + + +OpenApiApiKeyLocation: typing.TypeAlias = typing.Literal["header", "query", "cookie"] + + +class OpenApiSecuritySchemeModel(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", populate_by_name=True) + + +class OpenApiHttpSecurityScheme(OpenApiSecuritySchemeModel): + type: typing.Literal["http"] = "http" + scheme: str + bearer_format: str | None = pydantic.Field(default=None, alias="bearerFormat") + description: str | None = None + + +class OpenApiApiKeySecurityScheme(OpenApiSecuritySchemeModel): + type: typing.Literal["apiKey"] = "apiKey" + name: str + location: OpenApiApiKeyLocation = pydantic.Field(alias="in") + description: str | None = None + + +class OpenApiOAuthFlow(OpenApiSecuritySchemeModel): + authorization_url: str | None = pydantic.Field(default=None, alias="authorizationUrl") + token_url: str | None = pydantic.Field(default=None, alias="tokenUrl") + refresh_url: str | None = pydantic.Field(default=None, alias="refreshUrl") + scopes: dict[str, str] = pydantic.Field(default_factory=dict) + + @pydantic.field_validator("authorization_url", "token_url", "refresh_url") + @classmethod + def validate_url(cls, value: str | None) -> str | None: + return validate_openapi_url(value) + + +class OpenApiOAuthFlows(OpenApiSecuritySchemeModel): + implicit: OpenApiOAuthFlow | None = None + resource_owner: OpenApiOAuthFlow | None = pydantic.Field(default=None, alias="password") + client_credentials: OpenApiOAuthFlow | None = pydantic.Field(default=None, alias="clientCredentials") + authorization_code: OpenApiOAuthFlow | None = pydantic.Field(default=None, alias="authorizationCode") + + @pydantic.model_validator(mode="after") + def validate_required_urls(self) -> OpenApiOAuthFlows: + if all( + flow is None + for flow in (self.implicit, self.resource_owner, self.client_credentials, self.authorization_code) + ): + message = "OAuth2 flows must configure at least one grant type." + raise ValueError(message) + self._validate_flow_urls("implicit", self.implicit, requires_authorization_url=True) + self._validate_flow_urls("password", self.resource_owner, requires_token_url=True) + self._validate_flow_urls("client credentials", self.client_credentials, requires_token_url=True) + self._validate_flow_urls( + "authorization code", + self.authorization_code, + requires_authorization_url=True, + requires_token_url=True, + ) + return self + + @staticmethod + def _validate_flow_urls( + flow_name: str, + flow: OpenApiOAuthFlow | None, + *, + requires_authorization_url: bool = False, + requires_token_url: bool = False, + ) -> None: + if flow is None: + return + if requires_authorization_url and flow.authorization_url is None: + message = f"OAuth2 {flow_name} flow requires authorizationUrl." + raise ValueError(message) + if requires_token_url and flow.token_url is None: + message = f"OAuth2 {flow_name} flow requires tokenUrl." + raise ValueError(message) + + +class OpenApiOAuth2SecurityScheme(OpenApiSecuritySchemeModel): + type: typing.Literal["oauth2"] = "oauth2" + flows: OpenApiOAuthFlows + description: str | None = None + + +class OpenApiOpenIdConnectSecurityScheme(OpenApiSecuritySchemeModel): + type: typing.Literal["openIdConnect"] = "openIdConnect" + open_id_connect_url: str = pydantic.Field(alias="openIdConnectUrl") + description: str | None = None + + @pydantic.field_validator("open_id_connect_url") + @classmethod + def validate_url(cls, value: str) -> str: + validated_value = validate_openapi_url(value) + assert validated_value is not None # noqa: S101 - this field is required. + return validated_value + + +OpenApiSecurityScheme: typing.TypeAlias = typing.Annotated[ + OpenApiHttpSecurityScheme + | OpenApiApiKeySecurityScheme + | OpenApiOAuth2SecurityScheme + | OpenApiOpenIdConnectSecurityScheme, + pydantic.Field(discriminator="type"), +] + + +def validate_openapi_url(value: str | None) -> str | None: + if value is None: + return value + if not value or any(character.isspace() or unicodedata.category(character) == "Cc" for character in value): + message = "OpenAPI URL values must be non-empty and contain no whitespace or control characters." + raise ValueError(message) + return value + + +def serialize_security_schemes( + security_schemes: typing.Mapping[str, OpenApiSecurityScheme], +) -> dict[str, dict[str, typing.Any]]: + return { + scheme_name: security_scheme.model_dump(by_alias=True, exclude_none=True) + for scheme_name, security_scheme in security_schemes.items() + } diff --git a/microbootstrap/instruments/openapi_version_docs.py b/microbootstrap/instruments/openapi_version_docs.py new file mode 100644 index 0000000..eb4967c --- /dev/null +++ b/microbootstrap/instruments/openapi_version_docs.py @@ -0,0 +1,156 @@ +from __future__ import annotations +import re +import typing + +import pydantic + + +SUPPORTED_HTTP_METHODS: typing.Final = frozenset({"delete", "get", "head", "options", "patch", "post", "put", "trace"}) +SAFE_MEDIA_TYPE_TOKEN: typing.Final[re.Pattern[str]] = re.compile(r"[!#$%&'*+\-.^_|~0-9A-Za-z]+\Z") +VENDOR_MEDIA_TYPE: typing.Final = re.compile(r"application/vnd\.([!#$%&'*+\-.^_|~0-9A-Za-z]+)\+json\Z") + + +class OpenApiOperationSelector(pydantic.BaseModel): + path: str + method: str + + @pydantic.field_validator("path") + @classmethod + def validate_path(cls, value: str) -> str: + if not value.startswith("/") or "?" in value or "#" in value: + message = "Operation path must be an absolute path without a query string or fragment." + raise ValueError(message) + return value + + @pydantic.field_validator("method") + @classmethod + def validate_method(cls, value: str) -> str: + if value not in SUPPORTED_HTTP_METHODS: + message = f"Operation method must be one of: {', '.join(sorted(SUPPORTED_HTTP_METHODS))}." + raise ValueError(message) + return value + + +class OpenApiOperationVersionOverride(OpenApiOperationSelector): + supported_versions: tuple[str, ...] + + @pydantic.field_validator("supported_versions") + @classmethod + def validate_supported_versions(cls, value: tuple[str, ...]) -> tuple[str, ...]: + validated_versions = validate_versions(value) + if not validated_versions: + message = "Operation version overrides must contain at least one supported API version." + raise ValueError(message) + return validated_versions + + +class OpenApiVersionDocsConfig(pydantic.BaseModel): + enabled: bool = False + vendor_media_type: str | None = None + supported_versions: tuple[str, ...] = () + suppressed_operations: tuple[OpenApiOperationSelector, ...] = () + operation_versions: tuple[OpenApiOperationVersionOverride, ...] = () + + @pydantic.field_validator("vendor_media_type") + @classmethod + def validate_vendor_media_type(cls, value: str | None) -> str | None: + if value is None: + return value + if VENDOR_MEDIA_TYPE.fullmatch(value) is None: + message = "Vendor media type must use the application/vnd.+json form." + raise ValueError(message) + return value + + @pydantic.field_validator("supported_versions") + @classmethod + def validate_supported_versions(cls, value: tuple[str, ...]) -> tuple[str, ...]: + return validate_versions(value) + + @pydantic.field_validator("operation_versions") + @classmethod + def validate_operation_versions( + cls, + value: tuple[OpenApiOperationVersionOverride, ...], + ) -> tuple[OpenApiOperationVersionOverride, ...]: + selectors = {(override.path, override.method) for override in value} + if len(value) != len(selectors): + message = "Operation version overrides must not contain duplicate path and method pairs." + raise ValueError(message) + return value + + @pydantic.model_validator(mode="after") + def validate_enabled_configuration(self) -> OpenApiVersionDocsConfig: + if self.enabled and self.vendor_media_type is None: + message = "Enabled OpenAPI version documentation requires an explicit vendor media type." + raise ValueError(message) + if self.enabled and not self.supported_versions: + message = "Enabled OpenAPI version documentation requires at least one supported API version." + raise ValueError(message) + return self + + +def validate_versions(value: tuple[str, ...]) -> tuple[str, ...]: + if len(value) != len(set(value)): + message = "Supported API versions must not contain duplicates." + raise ValueError(message) + if any(SAFE_MEDIA_TYPE_TOKEN.fullmatch(version) is None for version in value): + message = "Each supported API version must be a non-empty safe media-type token." + raise ValueError(message) + return value + + +def get_supported_versions( + configuration: OpenApiVersionDocsConfig, + path: str, + method: str, +) -> tuple[str, ...]: + for override in configuration.operation_versions: + if override.path == path and override.method == method: + return override.supported_versions + return configuration.supported_versions + + +def build_accept_versioning_extension( + configuration: OpenApiVersionDocsConfig, + supported_versions: tuple[str, ...], +) -> dict[str, str | list[str]]: + assert configuration.vendor_media_type is not None # noqa: S101 - enabled configuration guarantees this. + return { + "header": "Accept", + "mediaType": configuration.vendor_media_type, + "parameter": "version", + "supportedVersions": list(supported_versions), + } + + +def build_version_documentation( + configuration: OpenApiVersionDocsConfig, + supported_versions: tuple[str, ...], +) -> str: + media_types: typing.Final = tuple( + f"{configuration.vendor_media_type}; version={version}" for version in supported_versions + ) + if len(media_types) == 1: + return f"Supported API version: `{media_types[0]}`." + return "Supported API versions:\n" + "\n".join(f"- `{media_type}`." for media_type in media_types) + + +def append_version_documentation( + description: str | None, + configuration: OpenApiVersionDocsConfig, + supported_versions: tuple[str, ...], +) -> str: + version_documentation: typing.Final = build_version_documentation(configuration, supported_versions) + if description is None or not description: + return version_documentation + if version_documentation in description: + return description + return f"{description}\n\n{version_documentation}" + + +def is_operation_suppressed( + configuration: OpenApiVersionDocsConfig, + path: str, + method: str, +) -> bool: + return OpenApiOperationSelector(path=path, method=method) in configuration.suppressed_operations diff --git a/microbootstrap/instruments/swagger_instrument.py b/microbootstrap/instruments/swagger_instrument.py index aca3765..47867b9 100644 --- a/microbootstrap/instruments/swagger_instrument.py +++ b/microbootstrap/instruments/swagger_instrument.py @@ -1,10 +1,18 @@ from __future__ import annotations +import re import typing import pydantic from microbootstrap.helpers import is_valid_path from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument +from microbootstrap.instruments.openapi_security_schemes import OpenApiSecurityScheme # noqa: TC001 +from microbootstrap.instruments.openapi_version_docs import ( + OpenApiVersionDocsConfig, # noqa: TC001 - Pydantic resolves it at runtime. +) + + +SECURITY_SCHEME_NAME_PATTERN: typing.Final = re.compile(r"^[a-zA-Z0-9._-]+$") class SwaggerConfig(BaseInstrumentConfig): @@ -16,6 +24,20 @@ class SwaggerConfig(BaseInstrumentConfig): swagger_path: str = "/docs" swagger_offline_docs: bool = False swagger_extra_params: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + security_schemes: dict[str, OpenApiSecurityScheme] = pydantic.Field(default_factory=dict) + openapi_version_docs: OpenApiVersionDocsConfig | None = None + + @pydantic.field_validator("security_schemes") + @classmethod + def validate_security_scheme_names( + cls, + security_schemes: dict[str, OpenApiSecurityScheme], + ) -> dict[str, OpenApiSecurityScheme]: + for scheme_name in security_schemes: + if SECURITY_SCHEME_NAME_PATTERN.fullmatch(scheme_name) is None: + message = "OpenAPI security scheme names must match ^[a-zA-Z0-9._-]+$." + raise ValueError(message) + return security_schemes class SwaggerInstrument(Instrument[SwaggerConfig]): diff --git a/pyproject.toml b/pyproject.toml index f1621b3..2434a86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,18 +63,18 @@ authors = [{ name = "community-of-python" }] [project.optional-dependencies] fastapi = [ - "fastapi>=0.100", + "fastapi>=0.110.1", "fastapi-offline-docs>=1", "opentelemetry-instrumentation-fastapi>=0.46b0", - "prometheus-fastapi-instrumentator>=6.1", + "prometheus-fastapi-instrumentator>=7.1", ] litestar = [ - "litestar>=2.9", + "litestar>=2.21.1", "litestar-offline-docs>=1", "prometheus-client>=0.20", ] granian = ["granian[reload]>=1"] -faststream = ["faststream~=0.6.2", "prometheus-client>=0.20"] +faststream = ["faststream>=0.6.7,<0.8", "prometheus-client>=0.20"] fastmcp = ["fastmcp>=2,<4", "prometheus-client>=0.20"] [dependency-groups] diff --git a/tests/bootstrappers/test_fastapi.py b/tests/bootstrappers/test_fastapi.py index 902ea6e..8c89a2a 100644 --- a/tests/bootstrappers/test_fastapi.py +++ b/tests/bootstrappers/test_fastapi.py @@ -3,6 +3,7 @@ from fastapi import status from fastapi.testclient import TestClient +from pydantic import BaseModel from microbootstrap.bootstrappers.fastapi import FastApiBootstrapper from microbootstrap.config.fastapi import FastApiConfig @@ -56,3 +57,52 @@ def test_fastapi_configure_application_lifespan(magic_mock: MagicMock) -> None: with TestClient(app=application): assert magic_mock.called + + +def test_fastapi_configure_application_openapi_and_documentation_options() -> None: + class Widget(BaseModel): + name: str + generated: str = "server-default" + + application: typing.Final = ( + FastApiBootstrapper(FastApiSettings(service_debug=False)) + .configure_application( + FastApiConfig( + title="Widgets API", + summary="Widget contract", + description="Configure widgets", + version="1.0.0", + openapi_url="/widget-schema.json", + docs_url="/widget-docs", + redoc_url="/widget-redoc", + separate_input_output_schemas=False, + ) + ) + .bootstrap() + ) + + @application.post("/widgets", response_model=Widget) + async def create_widget(widget: Widget) -> Widget: + return widget + + with TestClient(app=application) as test_client: + schema_response: typing.Final = test_client.get("/widget-schema.json") + swagger_response: typing.Final = test_client.get("/widget-docs") + redoc_response: typing.Final = test_client.get("/widget-redoc") + + assert schema_response.status_code == status.HTTP_200_OK + assert schema_response.json()["info"] == { + "title": "Widgets API", + "summary": "Widget contract", + "description": "Configure widgets", + "version": "1.0.0", + } + assert ( + schema_response.json()["components"]["schemas"]["Widget"]["properties"]["generated"]["default"] + == "server-default" + ) + assert application.separate_input_output_schemas is False + assert swagger_response.status_code == status.HTTP_200_OK + assert "/widget-schema.json" in swagger_response.text + assert redoc_response.status_code == status.HTTP_200_OK + assert "/widget-schema.json" in redoc_response.text diff --git a/tests/bootstrappers/test_faststream.py b/tests/bootstrappers/test_faststream.py index 35f14fe..e11dee5 100644 --- a/tests/bootstrappers/test_faststream.py +++ b/tests/bootstrappers/test_faststream.py @@ -106,13 +106,11 @@ async def test_ok(self, broker: RedisBroker) -> None: @pytest.mark.parametrize("conversation_id", ["authoritative-value", None]) async def test_faststream_opentelemetry( - monkeypatch: pytest.MonkeyPatch, faker: faker.Faker, broker: RedisBroker, minimal_opentelemetry_config: OpentelemetryConfig, conversation_id: str | None, ) -> None: - monkeypatch.setattr("opentelemetry.sdk.trace.TracerProvider.shutdown", mock.Mock()) input_channel: typing.Final = faker.pystr() output_channel: typing.Final = faker.pystr() conversation_id_span_attribute: typing.Final = "conversation.id" diff --git a/tests/bootstrappers/test_litestar_opentelemetry.py b/tests/bootstrappers/test_litestar_opentelemetry.py index 71e1be6..fa82217 100644 --- a/tests/bootstrappers/test_litestar_opentelemetry.py +++ b/tests/bootstrappers/test_litestar_opentelemetry.py @@ -4,8 +4,10 @@ import litestar import pytest from litestar.contrib.opentelemetry.config import OpenTelemetryConfig as LitestarOpentelemetryConfig -from litestar.status_codes import HTTP_200_OK +from litestar.status_codes import HTTP_200_OK, HTTP_201_CREATED from litestar.testing import TestClient +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.trace import Span, SpanKind from microbootstrap import LitestarSettings from microbootstrap.bootstrappers.litestar import ( @@ -18,6 +20,11 @@ from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig +def _require_span_attributes(span: ReadableSpan) -> typing.Mapping[str, object]: + assert span.attributes is not None + return span.attributes + + @pytest.mark.parametrize( ("scope", "expected_span_name", "expected_attributes"), [ @@ -162,6 +169,74 @@ async def root() -> dict[str, str]: assert mock_function.call_args_list[0].args[0].get("path_template") == expected_path_template +def test_litestar_opentelemetry_body_post_hooks_export_completed_spans( + minimal_opentelemetry_config: OpentelemetryConfig, + in_memory_otel: typing.Any, # noqa: ANN401 +) -> None: + expected_request_count: typing.Final = 2 + server_hook_calls: list[tuple[Span, dict[str, typing.Any]]] = [] + client_hook_calls: list[tuple[Span, dict[str, typing.Any], dict[str, typing.Any]]] = [] + + def server_request_hook(span: Span, scope: dict[str, typing.Any]) -> None: + server_hook_calls.append((span, scope)) + span.set_attribute("test.server.scope_path", scope["path"]) + + def client_message_hook(span: Span, scope: dict[str, typing.Any], message: dict[str, typing.Any]) -> None: + client_hook_calls.append((span, scope, message)) + span.set_attribute("test.client.message_type", message["type"]) + + instrument = LitestarOpentelemetryInstrument(minimal_opentelemetry_config) + instrument.bootstrap() + bootstrap_result = instrument.bootstrap_before() + middleware = bootstrap_result["middleware"][0] + assert isinstance(middleware, LitestarOpenTelemetryInstrumentationMiddleware) + middleware.config.server_request_hook_handler = server_request_hook + middleware.config.client_request_hook_handler = client_message_hook + middleware.config.client_response_hook_handler = client_message_hook + + @litestar.post("/widgets/{widget_id:int}") + async def create_widget(widget_id: int, data: dict[str, str]) -> dict[str, str | int]: + return {"widget_id": widget_id, "name": data["name"]} + + application = litestar.Litestar(route_handlers=[create_widget], **bootstrap_result) + with TestClient(app=application) as client: + first_response = client.post("/widgets/41", json={"name": "first"}) + second_response = client.post("/widgets/42", json={"name": "second"}) + + assert first_response.status_code == second_response.status_code == HTTP_201_CREATED + assert [scope["path"] for _, scope in server_hook_calls] == ["/widgets/41", "/widgets/42"] + assert all(message["type"].startswith("http.") for _, _, message in client_hook_calls) + assert in_memory_otel.providers[-1].force_flush(timeout_millis=1_000) + + spans: list[ReadableSpan] = in_memory_otel.exporters[-1].get_finished_spans() + server_spans = [ + span + for span in spans + if span.kind == SpanKind.SERVER + and span.name == "POST /widgets/{widget_id}" + and _require_span_attributes(span)["http.route"] == "/widgets/{widget_id}" + ] + assert len(server_spans) == expected_request_count + assert all(_require_span_attributes(span)["http.status_code"] == HTTP_201_CREATED for span in server_spans) + assert {_require_span_attributes(span)["test.server.scope_path"] for span in server_spans} == { + "/widgets/41", + "/widgets/42", + } + assert {_require_span_attributes(span).get("test.client.message_type") for span in spans} >= { + "http.request", + "http.response.start", + } + assert all( + event.attributes is None or event.attributes.get("exception.type") != "TypeError" + for span in spans + for event in span.events + ) + + for server_span in server_spans: + assert server_span.parent is None + assert any(span.parent is not None and span.parent.span_id == server_span.context.span_id for span in spans) + + def test_litestar_opentelemetry_middleware_initialization() -> None: mock_app: typing.Final = Mock() diff --git a/tests/bootstrappers/test_openapi_version_docs.py b/tests/bootstrappers/test_openapi_version_docs.py new file mode 100644 index 0000000..69a582a --- /dev/null +++ b/tests/bootstrappers/test_openapi_version_docs.py @@ -0,0 +1,869 @@ +import copy +import dataclasses +import typing +from unittest.mock import MagicMock + +import fastapi +import litestar +import pytest +from fastapi.security import HTTPBearer +from fastapi.testclient import TestClient as FastAPITestClient +from litestar import get, openapi, post, status_codes +from litestar.openapi import spec as litestar_openapi +from litestar.openapi.plugins import SwaggerRenderPlugin +from litestar.testing import TestClient as LitestarTestClient +from pydantic import BaseModel + +from microbootstrap import ( + OpenApiApiKeySecurityScheme, + OpenApiHttpSecurityScheme, + OpenApiOAuth2SecurityScheme, + OpenApiOAuthFlow, + OpenApiOAuthFlows, + OpenApiOpenIdConnectSecurityScheme, + OpenApiOperationSelector, + OpenApiOperationVersionOverride, + OpenApiSecurityScheme, + OpenApiVersionDocsConfig, + SwaggerConfig, +) +from microbootstrap.bootstrappers.fastapi import FastApiBootstrapper, FastApiSwaggerInstrument +from microbootstrap.bootstrappers.fastapi import add_security_schemes as add_fastapi_security_schemes +from microbootstrap.bootstrappers.litestar import ( + LitestarBootstrapper, + LitestarSwaggerInstrument, + add_accept_versioning_extension, +) +from microbootstrap.config.litestar import LitestarConfig +from microbootstrap.instruments.openapi_security_schemes import serialize_security_schemes +from microbootstrap.settings import FastApiSettings, LitestarSettings + + +TARGET_PATH: typing.Final = "/widgets" +UNCHANGED_PATH: typing.Final = "/service-health" +MISSING_DESCRIPTION_PATH: typing.Final = "/without-description" +VERSION_DOCUMENTATION: typing.Final = ( + "Supported API versions:\n" + "- `application/vnd.real-api+json; version=2026-01`.\n" + "- `application/vnd.real-api+json; version=release-candidate`." +) +GET_DESCRIPTION: typing.Final = "List widgets" +POST_DESCRIPTION: typing.Final = "Create widget" +LATE_OPERATION_DESCRIPTION: typing.Final = "Changed after the schema was first served" +EXPECTED_GENERATOR_CALLS: typing.Final = 2 + + +class ServiceOwnedResponse(BaseModel): + status: str + + +@dataclasses.dataclass +class BuiltSwaggerApplication: + framework: str + application: fastapi.FastAPI | litestar.Litestar + schema_path: str + renderer: SwaggerRenderPlugin | None = None + + def schema(self) -> dict[str, typing.Any]: + if isinstance(self.application, litestar.Litestar): + assert self.application.openapi_schema is not None + return self.application.openapi_schema.to_schema() + return self.application.openapi() + + +@pytest.fixture(params=("fastapi", "litestar")) +def swagger_framework(request: pytest.FixtureRequest) -> str: + return typing.cast("str", request.param) + + +def build_version_docs_config( + suppressed_operations: tuple[OpenApiOperationSelector, ...] = (), + operation_versions: tuple[OpenApiOperationVersionOverride, ...] = (), +) -> OpenApiVersionDocsConfig: + return OpenApiVersionDocsConfig( + enabled=True, + vendor_media_type="application/vnd.real-api+json", + supported_versions=("2026-01", "release-candidate"), + suppressed_operations=suppressed_operations, + operation_versions=operation_versions, + ) + + +def build_expected_description(description: object, supported_versions: tuple[str, ...]) -> str: + version_documentation = "\n".join( + f"- `application/vnd.real-api+json; version={version}`." for version in supported_versions + ) + if len(supported_versions) == 1: + version_documentation = version_documentation.removeprefix("- ").removesuffix(".") + "." + version_documentation = f"Supported API version: {version_documentation}" + else: + version_documentation = f"Supported API versions:\n{version_documentation}" + if not isinstance(description, str) or not description: + return version_documentation + return f"{description}\n\n{version_documentation}" + + +def build_expected_documented_schema( + original_schema: dict[str, typing.Any], + configuration: OpenApiVersionDocsConfig, +) -> dict[str, typing.Any]: + expected_schema: typing.Final = copy.deepcopy(original_schema) + suppressed_pairs: typing.Final = { + (selector.path, selector.method) for selector in configuration.suppressed_operations + } + overrides: typing.Final = { + (override.path, override.method): override.supported_versions for override in configuration.operation_versions + } + for path, path_item in expected_schema["paths"].items(): + if not isinstance(path_item, dict): + continue + for method, operation in path_item.items(): + if method not in {"delete", "get", "head", "options", "patch", "post", "put", "trace"}: + continue + if (path, method) in suppressed_pairs or not isinstance(operation, dict): + continue + supported_versions = overrides.get((path, method), configuration.supported_versions) + operation["description"] = build_expected_description(operation.get("description"), supported_versions) + operation["x-accept-versioning"] = { + "header": "Accept", + "mediaType": configuration.vendor_media_type, + "parameter": "version", + "supportedVersions": list(supported_versions), + } + return expected_schema + + +def build_litestar_application( + version_docs_config: OpenApiVersionDocsConfig | None, + startup_hook: MagicMock | None = None, + shutdown_hook: MagicMock | None = None, + security_schemes: dict[str, OpenApiSecurityScheme] | None = None, +) -> BuiltSwaggerApplication: + @get(TARGET_PATH, description=GET_DESCRIPTION, security=[{"ServiceAuth": []}]) + async def list_widgets() -> ServiceOwnedResponse: + return ServiceOwnedResponse(status="ok") + + @post(TARGET_PATH, description=POST_DESCRIPTION) + async def create_widget() -> ServiceOwnedResponse: + return ServiceOwnedResponse(status="created") + + @get(UNCHANGED_PATH, description="Service health") + async def service_health() -> dict[str, str]: + return {"status": "ok"} + + @get(MISSING_DESCRIPTION_PATH) + async def list_widgets_without_description() -> dict[str, str]: + return {"status": "ok"} + + renderer: typing.Final = SwaggerRenderPlugin() + service_owned_components: typing.Final = litestar_openapi.Components( + security_schemes={ + "GlobalAuth": litestar_openapi.SecurityScheme(type="http", scheme="bearer"), + "ServiceAuth": litestar_openapi.SecurityScheme(type="http", scheme="bearer"), + }, + ) + application: typing.Final = ( + LitestarBootstrapper( + LitestarSettings( + service_debug=False, + security_schemes=security_schemes or {}, + openapi_version_docs=version_docs_config, + ) + ) + .configure_application( + LitestarConfig( + route_handlers=[list_widgets, create_widget, service_health, list_widgets_without_description], + on_startup=[startup_hook] if startup_hook is not None else [], + on_shutdown=[shutdown_hook] if shutdown_hook is not None else [], + openapi_config=openapi.OpenAPIConfig( + title="Service API", + version="1.0.0", + components=service_owned_components, + security=[{"GlobalAuth": []}], + render_plugins=[renderer], + ), + ) + ) + .bootstrap() + ) + return BuiltSwaggerApplication("litestar", application, "/schema/openapi.json", renderer) + + +def build_fastapi_application( + version_docs_config: OpenApiVersionDocsConfig | None, + security_schemes: dict[str, OpenApiSecurityScheme] | None = None, +) -> BuiltSwaggerApplication: + application: typing.Final = FastApiBootstrapper( + FastApiSettings( + service_debug=False, + security_schemes=security_schemes or {}, + openapi_version_docs=version_docs_config, + ) + ).bootstrap() + service_authentication: typing.Final = HTTPBearer() + + @application.get(TARGET_PATH, description=GET_DESCRIPTION, dependencies=[fastapi.Security(service_authentication)]) + async def list_widgets() -> dict[str, str]: + return {"status": "ok"} + + @application.post(TARGET_PATH, description=POST_DESCRIPTION) + async def create_widget() -> dict[str, str]: + return {"status": "created"} + + @application.get(UNCHANGED_PATH, description="Service health") + async def service_health() -> dict[str, str]: + return {"status": "ok"} + + @application.get(MISSING_DESCRIPTION_PATH) + async def list_widgets_without_description() -> dict[str, str]: + return {"status": "ok"} + + return BuiltSwaggerApplication("fastapi", application, "/openapi.json") + + +def build_application( + framework: str, + version_docs_config: OpenApiVersionDocsConfig | None, + security_schemes: dict[str, OpenApiSecurityScheme] | None = None, +) -> BuiltSwaggerApplication: + if framework == "litestar": + return build_litestar_application(version_docs_config, security_schemes=security_schemes) + return build_fastapi_application(version_docs_config, security_schemes) + + +def request_schema(application: BuiltSwaggerApplication) -> dict[str, typing.Any]: + if application.framework == "litestar": + assert isinstance(application.application, litestar.Litestar) + with LitestarTestClient(app=application.application) as test_client: + response = test_client.get(application.schema_path) + else: + assert isinstance(application.application, fastapi.FastAPI) + with FastAPITestClient(app=application.application) as test_client: + response = test_client.get(application.schema_path) + assert response.status_code == status_codes.HTTP_200_OK + return typing.cast("dict[str, typing.Any]", response.json()) + + +def create_widget(application: BuiltSwaggerApplication) -> None: + expected_status: int + if application.framework == "litestar": + assert isinstance(application.application, litestar.Litestar) + with LitestarTestClient(app=application.application) as test_client: + response = test_client.post(TARGET_PATH) + expected_status = status_codes.HTTP_201_CREATED + else: + assert isinstance(application.application, fastapi.FastAPI) + with FastAPITestClient(app=application.application) as test_client: + response = test_client.post(TARGET_PATH) + expected_status = status_codes.HTTP_200_OK + assert response.status_code == expected_status + assert response.headers["content-type"] == "application/json" + assert response.json() == {"status": "created"} + + +def test_production_version_docs_only_change_expected_operation_descriptions(swagger_framework: str) -> None: + suppressed_operations: typing.Final = (OpenApiOperationSelector(path=TARGET_PATH, method="get"),) + baseline_application: typing.Final = build_application(swagger_framework, None) + original_schema: typing.Final = baseline_application.schema() + version_docs_config: typing.Final = build_version_docs_config(suppressed_operations) + application: typing.Final = build_application(swagger_framework, version_docs_config) + expected_schema: typing.Final = build_expected_documented_schema(original_schema, version_docs_config) + + early_schema: typing.Final = application.schema() + repeated_schema: typing.Final = application.schema() + first_served_schema: typing.Final = request_schema(application) + second_served_schema: typing.Final = request_schema(application) + + assert early_schema == expected_schema + assert repeated_schema == expected_schema + assert first_served_schema == expected_schema + assert second_served_schema == expected_schema + assert early_schema["components"] == original_schema["components"] + assert early_schema.get("security") == original_schema.get("security") + assert early_schema["paths"][TARGET_PATH]["get"] == original_schema["paths"][TARGET_PATH]["get"] + assert early_schema["paths"][TARGET_PATH]["post"]["description"] == build_expected_description( + POST_DESCRIPTION, + version_docs_config.supported_versions, + ) + assert early_schema["paths"][MISSING_DESCRIPTION_PATH]["get"]["description"] == VERSION_DOCUMENTATION + assert early_schema["paths"][TARGET_PATH]["post"]["description"].count(VERSION_DOCUMENTATION) == 1 + assert "x-accept-versioning" not in early_schema["paths"][TARGET_PATH]["get"] + assert early_schema["paths"][TARGET_PATH]["post"]["x-accept-versioning"] == { + "header": "Accept", + "mediaType": "application/vnd.real-api+json", + "parameter": "version", + "supportedVersions": ["2026-01", "release-candidate"], + } + create_widget(application) + + +@pytest.mark.parametrize("version_docs_config", [None, OpenApiVersionDocsConfig()]) +def test_absent_or_disabled_version_docs_preserve_the_whole_schema( + swagger_framework: str, + version_docs_config: OpenApiVersionDocsConfig | None, +) -> None: + baseline_application: typing.Final = build_application(swagger_framework, None) + baseline_schema: typing.Final = baseline_application.schema() + application: typing.Final = build_application(swagger_framework, version_docs_config) + + assert application.schema() == baseline_schema + assert request_schema(application) == baseline_schema + create_widget(application) + + +def test_production_version_docs_use_singular_format_for_one_supported_version(swagger_framework: str) -> None: + configuration: typing.Final = OpenApiVersionDocsConfig( + enabled=True, + vendor_media_type="application/vnd.real-api+json", + supported_versions=("2026-01",), + ) + application: typing.Final = build_application(swagger_framework, configuration) + + assert application.schema()["paths"][TARGET_PATH]["post"]["description"] == ( + f"{POST_DESCRIPTION}\n\nSupported API version: `application/vnd.real-api+json; version=2026-01`." + ) + + +def test_operation_version_override_and_accept_requests_do_not_change_runtime(swagger_framework: str) -> None: + version_docs_config: typing.Final = build_version_docs_config( + operation_versions=( + OpenApiOperationVersionOverride( + path=TARGET_PATH, + method="post", + supported_versions=("2027-01",), + ), + ), + ) + baseline_application: typing.Final = build_application(swagger_framework, None) + baseline_schema: typing.Final = baseline_application.schema() + application: typing.Final = build_application(swagger_framework, version_docs_config) + schema: typing.Final = application.schema() + + assert schema["paths"][TARGET_PATH]["post"]["x-accept-versioning"] == { + "header": "Accept", + "mediaType": "application/vnd.real-api+json", + "parameter": "version", + "supportedVersions": ["2027-01"], + } + assert schema["paths"][TARGET_PATH]["post"]["description"] == build_expected_description( + POST_DESCRIPTION, + ("2027-01",), + ) + assert schema["paths"][TARGET_PATH]["post"].get("parameters") == baseline_schema["paths"][TARGET_PATH]["post"].get( + "parameters" + ) + assert schema["paths"][TARGET_PATH]["post"].get("responses") == baseline_schema["paths"][TARGET_PATH]["post"].get( + "responses" + ) + + for headers in ({}, {"Accept": "application/vnd.real-api+json; version=2027-01"}): + if isinstance(application.application, litestar.Litestar): + with LitestarTestClient(app=application.application) as test_client: + response = test_client.post(TARGET_PATH, headers=headers) + expected_status = status_codes.HTTP_201_CREATED + else: + with FastAPITestClient(app=application.application) as test_client: + response = test_client.post(TARGET_PATH, headers=headers) + expected_status = status_codes.HTTP_200_OK + assert response.status_code == expected_status + assert response.headers["content-type"] == "application/json" + assert response.json() == {"status": "created"} + + +def build_security_schemes() -> dict[str, OpenApiSecurityScheme]: + return { + "httpAuth": OpenApiHttpSecurityScheme(scheme="bearer", bearer_format="JWT"), + "apiKeyAuth": OpenApiApiKeySecurityScheme(name="X-API-Key", location="header"), + "oauth": OpenApiOAuth2SecurityScheme( + flows=OpenApiOAuthFlows( + implicit=OpenApiOAuthFlow(authorization_url="/authorize", scopes={"read": "Read widgets"}), + resource_owner=OpenApiOAuthFlow(token_url="/token", scopes={"write": "Write widgets"}), # noqa: S106 + client_credentials=OpenApiOAuthFlow(token_url="/client-token", scopes={}), # noqa: S106 + authorization_code=OpenApiOAuthFlow( + authorization_url="/code-authorize", + token_url="/code-token", # noqa: S106 + refresh_url="/refresh", + scopes={"admin": "Administer widgets"}, + ), + ) + ), + "oidc": OpenApiOpenIdConnectSecurityScheme(open_id_connect_url="/.well-known/openid-configuration"), + } + + +def test_security_schemes_preserve_service_owned_schema_and_security(swagger_framework: str) -> None: + security_schemes: typing.Final = build_security_schemes() + baseline_application: typing.Final = build_application(swagger_framework, None) + baseline_schema: typing.Final = baseline_application.schema() + application: typing.Final = build_application(swagger_framework, None, security_schemes) + schema: typing.Final = application.schema() + + assert schema["components"].get("schemas") == baseline_schema["components"].get("schemas") + assert schema["components"]["securitySchemes"] == { + **baseline_schema["components"]["securitySchemes"], + **serialize_security_schemes(security_schemes), + } + assert schema.get("security") == baseline_schema.get("security") + assert schema["paths"][TARGET_PATH]["get"].get("security") == baseline_schema["paths"][TARGET_PATH]["get"].get( + "security" + ) + assert application.schema() == schema + + +def test_security_schemes_combine_with_accept_version_documentation(swagger_framework: str) -> None: + security_schemes: typing.Final = build_security_schemes() + application: typing.Final = build_application( + swagger_framework, + build_version_docs_config(), + security_schemes, + ) + schema: typing.Final = application.schema() + + assert schema["components"]["securitySchemes"].items() >= serialize_security_schemes(security_schemes).items() + assert schema["paths"][TARGET_PATH]["post"]["x-accept-versioning"] == { + "header": "Accept", + "mediaType": "application/vnd.real-api+json", + "parameter": "version", + "supportedVersions": ["2026-01", "release-candidate"], + } + + +@pytest.mark.parametrize( + ("failure", "error"), + [ + ("conflicting_extension", "x-accept-versioning conflicts"), + ("non_string_description", "has a non-string description"), + ], +) +def test_fastapi_combined_openapi_augmentation_is_atomic_and_retryable(failure: str, error: str) -> None: + service_schema: dict[str, typing.Any] = { + "openapi": "3.1.0", + "info": {"title": "Service API", "version": "1.0.0"}, + "paths": { + TARGET_PATH: { + "get": {"description": GET_DESCRIPTION}, + "post": {"description": POST_DESCRIPTION}, + } + }, + "components": {"securitySchemes": {}}, + } + failing_operation = service_schema["paths"][TARGET_PATH]["post"] + if failure == "conflicting_extension": + failing_operation["x-accept-versioning"] = {"header": "X-Service-Version"} + else: + failing_operation["description"] = 1 + baseline_schema: typing.Final = copy.deepcopy(service_schema) + application: typing.Final = fastapi.FastAPI() + application.openapi = lambda: service_schema # type: ignore[method-assign] # FastAPI's public custom OpenAPI hook. + instrument: typing.Final = FastApiSwaggerInstrument( + SwaggerConfig( + security_schemes={"serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer")}, + openapi_version_docs=build_version_docs_config(), + ) + ) + instrument.bootstrap_after(application) + + with pytest.raises(ValueError, match=error): + application.openapi() + + assert service_schema == baseline_schema + assert application.openapi_schema is None + if failure == "conflicting_extension": + failing_operation.pop("x-accept-versioning") + else: + failing_operation["description"] = POST_DESCRIPTION + + first_schema: typing.Final = application.openapi() + second_schema: typing.Final = application.openapi() + with FastAPITestClient(application) as client: + served_schema: typing.Final = client.get("/openapi.json") + + assert first_schema is second_schema is service_schema + assert first_schema["components"]["securitySchemes"]["serviceAuth"] == {"type": "http", "scheme": "bearer"} + assert first_schema["paths"][TARGET_PATH]["get"]["x-accept-versioning"]["header"] == "Accept" + assert served_schema.status_code == status_codes.HTTP_200_OK + assert served_schema.json() == first_schema + + +@pytest.mark.parametrize( + ("failure", "error"), + [ + ("conflicting_extension", "x-accept-versioning conflicts"), + ("non_string_description", "has a non-string description"), + ], +) +def test_litestar_combined_openapi_augmentation_is_atomic_and_retryable(failure: str, error: str) -> None: + built_application: typing.Final = build_litestar_application(None) + application: typing.Final = built_application.application + assert isinstance(application, litestar.Litestar) + assert application.openapi_schema is not None + assert application.openapi_schema.paths is not None + path_item: typing.Final = application.openapi_schema.paths[TARGET_PATH] + assert isinstance(path_item, litestar_openapi.PathItem) + assert path_item.get is not None + assert path_item.post is not None + original_get: typing.Final = path_item.get + if failure == "conflicting_extension": + path_item.post = add_accept_versioning_extension(path_item.post, {"header": "X-Service-Version"}) + else: + path_item.post.description = 1 # type: ignore[assignment] # Deliberately invalid service-owned schema. + baseline_schema: typing.Final = copy.deepcopy(application.openapi_schema.to_schema()) + instrument: typing.Final = LitestarSwaggerInstrument( + SwaggerConfig( + security_schemes={"serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer")}, + openapi_version_docs=build_version_docs_config(), + ) + ) + + with pytest.raises(ValueError, match=error): + instrument.bootstrap_after(application) + + assert application.openapi_schema.to_schema() == baseline_schema + assert path_item.get is original_get + assert application.openapi_schema.components.security_schemes is not None + assert "serviceAuth" not in application.openapi_schema.components.security_schemes + if failure == "conflicting_extension": + assert path_item.post is not None + extension_field = next( + field + for field in dataclasses.fields(path_item.post) + if field.metadata.get("alias") == "x-accept-versioning" + ) + object.__setattr__(path_item.post, extension_field.name, None) + else: + path_item.post.description = POST_DESCRIPTION + + instrument.bootstrap_after(application) + documented_get: typing.Final = path_item.get + instrument.bootstrap_after(application) + assert path_item.get is documented_get + with LitestarTestClient(application) as client: + first_served_schema: typing.Final = client.get("/schema/openapi.json") + second_served_schema: typing.Final = client.get("/schema/openapi.json") + + assert application.openapi_schema.to_schema()["components"]["securitySchemes"]["serviceAuth"] == { + "type": "http", + "scheme": "bearer", + } + assert first_served_schema.status_code == second_served_schema.status_code == status_codes.HTTP_200_OK + assert first_served_schema.json() == second_served_schema.json() == application.openapi_schema.to_schema() + + +def test_fastapi_security_scheme_collisions_are_atomic_and_identical_schemes_are_retained() -> None: + expected_scheme: typing.Final = {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"} + service_schema: dict[str, typing.Any] = { + "openapi": "3.1.0", + "info": {"title": "Service API", "version": "1.0.0"}, + "paths": {}, + "components": { + "schemas": {"ServiceOwned": {"type": "object"}}, + "securitySchemes": {"matching": expected_scheme, "conflicting": {"type": "http", "scheme": "basic"}}, + }, + } + application: typing.Final = fastapi.FastAPI() + application.openapi = lambda: service_schema # type: ignore[method-assign] # FastAPI's public custom OpenAPI hook. + instrument: typing.Final = FastApiSwaggerInstrument( + SwaggerConfig( + security_schemes={ + "matching": OpenApiHttpSecurityScheme(scheme="bearer", bearer_format="JWT"), + } + ) + ) + instrument.bootstrap_after(application) + + assert application.openapi() is service_schema + assert service_schema["components"] == { + "schemas": {"ServiceOwned": {"type": "object"}}, + "securitySchemes": {"matching": expected_scheme, "conflicting": {"type": "http", "scheme": "basic"}}, + } + + with pytest.raises(ValueError, match="security scheme 'conflicting' conflicts"): + add_fastapi_security_schemes( + service_schema, + { + "insert": OpenApiApiKeySecurityScheme(name="X-API-Key", location="header"), + "conflicting": OpenApiHttpSecurityScheme(scheme="bearer"), + }, + ) + assert "insert" not in service_schema["components"]["securitySchemes"] + + +def test_litestar_security_scheme_collisions_are_atomic() -> None: + application: typing.Final = build_litestar_application(None).application + assert isinstance(application, litestar.Litestar) + assert application.openapi_schema is not None + assert application.openapi_schema.components.security_schemes is not None + application.openapi_schema.components.security_schemes["conflicting"] = litestar_openapi.SecurityScheme( + type="http", + scheme="basic", + ) + + with pytest.raises(ValueError, match="security scheme 'conflicting' conflicts"): + LitestarSwaggerInstrument( + SwaggerConfig( + security_schemes={ + "insert": OpenApiApiKeySecurityScheme(name="X-API-Key", location="header"), + "conflicting": OpenApiHttpSecurityScheme(scheme="bearer"), + } + ) + ).bootstrap_after(application) + assert "insert" not in application.openapi_schema.components.security_schemes + + +@pytest.mark.parametrize("settings_type", [FastApiSettings, LitestarSettings]) +def test_openapi_settings_validate_security_schemes_and_operation_versions( + settings_type: type[FastApiSettings] | type[LitestarSettings], +) -> None: + settings: typing.Final = settings_type( + security_schemes={"serviceBearer": {"type": "http", "scheme": "bearer"}}, + openapi_version_docs={ + "enabled": True, + "vendor_media_type": "application/vnd.real-api+json", + "supported_versions": ("2026-01",), + "operation_versions": ({"path": TARGET_PATH, "method": "post", "supported_versions": ("2027-01",)},), + }, + ) + + assert settings.security_schemes == {"serviceBearer": OpenApiHttpSecurityScheme(scheme="bearer")} + assert settings.openapi_version_docs is not None + assert settings.openapi_version_docs.operation_versions == ( + OpenApiOperationVersionOverride(path=TARGET_PATH, method="post", supported_versions=("2027-01",)), + ) + + with pytest.raises(ValueError, match="must contain at least one"): + OpenApiOperationVersionOverride(path=TARGET_PATH, method="post", supported_versions=()) + + with pytest.raises(ValueError, match="duplicate path and method pairs"): + OpenApiVersionDocsConfig( + enabled=True, + vendor_media_type="application/vnd.real-api+json", + supported_versions=("2026-01",), + operation_versions=( + OpenApiOperationVersionOverride(path=TARGET_PATH, method="post", supported_versions=("2027-01",)), + OpenApiOperationVersionOverride(path=TARGET_PATH, method="post", supported_versions=("2028-01",)), + ), + ) + + +def test_fastapi_version_extension_conflict_is_not_overwritten() -> None: + application: typing.Final = fastapi.FastAPI() + application.openapi = lambda: { # type: ignore[method-assign] # FastAPI's public custom OpenAPI hook. + "openapi": "3.1.0", + "info": {"title": "Service API", "version": "1.0.0"}, + "paths": { + TARGET_PATH: { + "get": { + "description": GET_DESCRIPTION, + "x-accept-versioning": {"header": "X-Service-Version"}, + }, + }, + }, + } + FastApiSwaggerInstrument(SwaggerConfig(openapi_version_docs=build_version_docs_config())).bootstrap_after( + application + ) + + with pytest.raises(ValueError, match="x-accept-versioning conflicts"): + application.openapi() + + +def test_litestar_version_docs_preserve_service_owned_renderer_hooks_and_late_schema_cache() -> None: + startup_hook: typing.Final = MagicMock() + shutdown_hook: typing.Final = MagicMock() + application: typing.Final = build_litestar_application( + build_version_docs_config((OpenApiOperationSelector(path=TARGET_PATH, method="get"),)), + startup_hook, + shutdown_hook, + ) + assert isinstance(application.application, litestar.Litestar) + assert application.application.openapi_config is not None + assert application.application.openapi_config.render_plugins[0] is application.renderer + + expected_schema: typing.Final = application.schema() + with LitestarTestClient(app=application.application) as test_client: + first_served_schema: typing.Final = test_client.get(application.schema_path) + assert application.application.openapi_schema is not None + assert application.application.openapi_schema.paths is not None + target_path_item: typing.Final = application.application.openapi_schema.paths[TARGET_PATH] + assert isinstance(target_path_item, litestar_openapi.PathItem) + assert target_path_item.post is not None + target_path_item.post.description = LATE_OPERATION_DESCRIPTION + cached_schema: typing.Final = test_client.get(application.schema_path) + + assert first_served_schema.json() == expected_schema + assert cached_schema.json() == expected_schema + assert application.schema()["paths"][TARGET_PATH]["post"]["description"] == LATE_OPERATION_DESCRIPTION + startup_hook.assert_called_once_with(application.application) + shutdown_hook.assert_called_once_with(application.application) + + +def test_litestar_version_docs_preserve_custom_operation_subclasses_and_extensions() -> None: + expected_extension: typing.Final[dict[str, str | list[str]]] = { + "header": "Accept", + "mediaType": "application/vnd.real-api+json", + "parameter": "version", + "supportedVersions": ["2026-01", "release-candidate"], + } + + @dataclasses.dataclass(slots=True) + class ServiceMetadataOperation(litestar_openapi.Operation): + service_metadata: dict[str, str] | None = dataclasses.field( + default=None, + metadata={"alias": "x-service-metadata"}, + ) + rendering_state: str = dataclasses.field(init=False, default="draft") + + def service_owner(self) -> str | None: + if self.service_metadata is None: + return None + return self.service_metadata["owner"] + + def to_schema(self) -> dict[str, typing.Any]: + return {**super(ServiceMetadataOperation, self).to_schema(), "x-rendering-state": self.rendering_state} + + @dataclasses.dataclass(slots=True) + class ServiceVersionedOperation(ServiceMetadataOperation): + accept_versioning: dict[str, str | list[str]] | None = dataclasses.field( + default=None, + metadata={"alias": "x-accept-versioning"}, + ) + + def custom_litestar_operation_generator( + operation: litestar_openapi.Operation, + accept_versioning: dict[str, str | list[str]] | None = None, + ) -> ServiceMetadataOperation: + operation_fields: typing.Final = { + field.name: getattr(operation, field.name) + for field in dataclasses.fields(operation) + if field.name not in {"service_metadata", "accept_versioning", "rendering_state"} + } + if accept_versioning is None: + return ServiceMetadataOperation( + **operation_fields, + service_metadata={"owner": "widgets"}, + ) + return ServiceVersionedOperation( + **operation_fields, + service_metadata={"owner": "widgets"}, + accept_versioning=accept_versioning, + ) + + @get(TARGET_PATH, description=GET_DESCRIPTION) + async def list_service_widgets() -> dict[str, str]: + return {"status": "ok"} + + application: typing.Final = litestar.Litestar(route_handlers=[list_service_widgets]) + assert application.openapi_schema is not None + assert application.openapi_schema.paths is not None + path_item: typing.Final = application.openapi_schema.paths[TARGET_PATH] + assert isinstance(path_item, litestar_openapi.PathItem) + assert path_item.get is not None + service_owned_operation: typing.Final[ServiceVersionedOperation] = typing.cast( + "ServiceVersionedOperation", + custom_litestar_operation_generator(path_item.get, {}), + ) + service_owned_operation.accept_versioning = None + service_owned_operation.rendering_state = "published" + path_item.get = service_owned_operation + pre_adaptation_schema: typing.Final = service_owned_operation.to_schema() + assert pre_adaptation_schema["x-service-metadata"] == {"owner": "widgets"} + assert pre_adaptation_schema["x-rendering-state"] == "published" + assert "x-accept-versioning" not in pre_adaptation_schema + + instrument: typing.Final = LitestarSwaggerInstrument( + SwaggerConfig(openapi_version_docs=build_version_docs_config()) + ) + instrument.bootstrap_after(application) + + documented_operation: typing.Final = path_item.get + assert type(documented_operation) is ServiceVersionedOperation + assert documented_operation.service_owner() == "widgets" + assert documented_operation.to_schema()["x-service-metadata"] == {"owner": "widgets"} + assert documented_operation.to_schema()["x-rendering-state"] == "published" + assert documented_operation.to_schema()["x-accept-versioning"] == expected_extension + + instrument.bootstrap_after(application) + assert path_item.get is documented_operation + assert documented_operation.rendering_state == "published" + + canonical_schema: typing.Final = application.openapi_schema.to_schema() + with LitestarTestClient(app=application) as test_client: + served_schema: typing.Final = test_client.get("/schema/openapi.json") + + assert canonical_schema["paths"][TARGET_PATH]["get"]["x-service-metadata"] == {"owner": "widgets"} + assert canonical_schema["paths"][TARGET_PATH]["get"]["x-rendering-state"] == "published" + assert canonical_schema["paths"][TARGET_PATH]["get"]["x-accept-versioning"] == expected_extension + assert served_schema.status_code == status_codes.HTTP_200_OK + assert served_schema.json()["paths"][TARGET_PATH]["get"]["x-service-metadata"] == {"owner": "widgets"} + assert served_schema.json()["paths"][TARGET_PATH]["get"]["x-rendering-state"] == "published" + assert served_schema.json()["paths"][TARGET_PATH]["get"]["x-accept-versioning"] == expected_extension + + matching_operation: typing.Final = custom_litestar_operation_generator(service_owned_operation, expected_extension) + assert add_accept_versioning_extension(matching_operation, expected_extension) is matching_operation + + conflicting_operation: typing.Final = custom_litestar_operation_generator( + service_owned_operation, + {**expected_extension, "header": "X-Service-Version"}, + ) + with pytest.raises(ValueError, match="x-accept-versioning conflicts"): + add_accept_versioning_extension(conflicting_operation, expected_extension) + + +def test_fastapi_production_version_docs_preserve_custom_generator_and_errors() -> None: + application: typing.Final = fastapi.FastAPI() + invocation_count = 0 + service_schema: typing.Final = { + "openapi": "3.1.0", + "info": {"title": "Service API", "version": "1.0.0"}, + "paths": { + TARGET_PATH: { + "get": { + "description": GET_DESCRIPTION, + "responses": {"200": {"description": "OK"}}, + "security": [{"ServiceAuth": []}], + } + } + }, + "components": { + "schemas": {"ServiceOwned": {"type": "object"}}, + "securitySchemes": { + "GlobalAuth": {"type": "http", "scheme": "bearer"}, + "ServiceAuth": {"type": "http", "scheme": "bearer"}, + }, + }, + "security": [{"GlobalAuth": []}], + } + expected_schema: typing.Final = build_expected_documented_schema(service_schema, build_version_docs_config()) + + def service_owned_openapi() -> dict[str, typing.Any]: + nonlocal invocation_count + invocation_count += 1 + return service_schema + + application.openapi = service_owned_openapi # type: ignore[method-assign] # FastAPI's public custom OpenAPI hook. + FastApiSwaggerInstrument(SwaggerConfig(openapi_version_docs=build_version_docs_config())).bootstrap_after( + application + ) + + first_schema: typing.Final = application.openapi() + second_schema: typing.Final = application.openapi() + + assert invocation_count == EXPECTED_GENERATOR_CALLS + assert first_schema is service_schema + assert second_schema is service_schema + assert first_schema == expected_schema + assert first_schema["components"] == expected_schema["components"] + assert first_schema["security"] == expected_schema["security"] + assert first_schema["paths"][TARGET_PATH]["get"]["security"] == [{"ServiceAuth": []}] + + def failing_service_owned_openapi() -> dict[str, typing.Any]: + raise RuntimeError("service-owned generator failed") + + failing_application: typing.Final = fastapi.FastAPI() + failing_application.openapi = failing_service_owned_openapi # type: ignore[method-assign] # FastAPI's public hook. + FastApiSwaggerInstrument(SwaggerConfig(openapi_version_docs=build_version_docs_config())).bootstrap_after( + failing_application + ) + + with pytest.raises(RuntimeError, match="service-owned generator failed"): + failing_application.openapi() diff --git a/tests/conftest.py b/tests/conftest.py index dc8aeca..18e1271 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,13 @@ from __future__ import annotations +import dataclasses import importlib import typing from unittest.mock import AsyncMock, MagicMock import litestar import pytest +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter from prometheus_client import REGISTRY from sentry_sdk.transport import Transport as SentryTransport @@ -27,12 +30,56 @@ if typing.TYPE_CHECKING: + from opentelemetry.sdk.resources import Resource from sentry_sdk.envelope import Envelope as SentryEnvelope pytestmark = [pytest.mark.anyio] +@dataclasses.dataclass +class InMemoryOpenTelemetry: + exporters: list[InMemorySpanExporter] = dataclasses.field(default_factory=list) + exporter_calls: list[tuple[tuple[object, ...], dict[str, object]]] = dataclasses.field(default_factory=list) + providers: list[TracerProvider] = dataclasses.field(default_factory=list) + + @staticmethod + def _flush_provider(provider: TracerProvider) -> None: + if not provider.force_flush(timeout_millis=1_000): + raise AssertionError("force_flush returned False") + + @staticmethod + def _record_cleanup_failure( + operation: str, + cleanup: typing.Callable[[], None], + failures: list[tuple[str, Exception]], + ) -> None: + try: + cleanup() + except Exception as exc: # noqa: BLE001 - fixture cleanup must continue for every owned provider. + failures.append((operation, exc)) + + def cleanup(self) -> None: + failures: list[tuple[str, Exception]] = [] + for provider_index, provider in enumerate(self.providers): + + def flush_provider(selected_provider: TracerProvider = provider) -> None: + self._flush_provider(selected_provider) + + self._record_cleanup_failure( + f"provider {provider_index} force_flush", + flush_provider, + failures, + ) + + for provider_index, provider in enumerate(self.providers): + self._record_cleanup_failure(f"provider {provider_index} shutdown", provider.shutdown, failures) + + if failures: + details = "; ".join(f"{operation}: {failure!r}" for operation, failure in failures) + raise RuntimeError(f"OpenTelemetry fixture cleanup failed: {details}") from failures[0][1] + + @pytest.fixture(scope="session", autouse=True) def anyio_backend() -> str: return "asyncio" @@ -139,7 +186,32 @@ def reset_reloaded_settings_module() -> typing.Iterator[None]: @pytest.fixture(autouse=True) def patch_out_entry_points(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(opentelemetry_instrument, "entry_points", MagicMock(retrun_value=[])) + monkeypatch.setattr(opentelemetry_instrument, "entry_points", MagicMock(return_value=[])) + + +@pytest.fixture(autouse=True) +def in_memory_otel(monkeypatch: pytest.MonkeyPatch) -> typing.Iterator[InMemoryOpenTelemetry]: + """Keep real SDK providers/processors while replacing OTLP delivery at its boundary.""" + harness = InMemoryOpenTelemetry() + + def create_exporter(*args: object, **kwargs: object) -> InMemorySpanExporter: + harness.exporter_calls.append((args, kwargs)) + exporter = InMemorySpanExporter() + harness.exporters.append(exporter) + return exporter + + def create_provider(*, resource: Resource | None = None) -> TracerProvider: + provider = TracerProvider(resource=resource) + harness.providers.append(provider) + return provider + + monkeypatch.setattr(opentelemetry_instrument, "OTLPSpanExporter", create_exporter) + monkeypatch.setattr(opentelemetry_instrument, "SdkTracerProvider", create_provider) + + try: + yield harness + finally: + harness.cleanup() @pytest.fixture(autouse=True) diff --git a/tests/instruments/test_openapi_security_schemes.py b/tests/instruments/test_openapi_security_schemes.py new file mode 100644 index 0000000..a6cd387 --- /dev/null +++ b/tests/instruments/test_openapi_security_schemes.py @@ -0,0 +1,167 @@ +import typing + +import pytest +from pydantic import ValidationError + +from microbootstrap import OpenApiApiKeySecurityScheme as ApiKeySecurityScheme +from microbootstrap import ( + OpenApiHttpSecurityScheme, + OpenApiOAuth2SecurityScheme, + OpenApiOAuthFlow, + OpenApiOAuthFlows, + OpenApiOpenIdConnectSecurityScheme, + OpenApiSecurityScheme, + SwaggerConfig, +) +from microbootstrap.instruments.openapi_security_schemes import serialize_security_schemes + + +def test_security_schemes_accept_python_names_and_serialize_openapi_aliases() -> None: + security_schemes: typing.Final[dict[str, OpenApiSecurityScheme]] = { + "http.auth": OpenApiHttpSecurityScheme(scheme="bearer", bearer_format="JWT"), + "api-key": ApiKeySecurityScheme(name="X-API-Key", location="header"), + "oauth2": OpenApiOAuth2SecurityScheme( + flows=OpenApiOAuthFlows( + implicit=OpenApiOAuthFlow(authorization_url="/authorize", scopes={"read": "Read access"}), + resource_owner=OpenApiOAuthFlow(token_url="/token", scopes={"write": "Write access"}), # noqa: S106 + client_credentials=OpenApiOAuthFlow(token_url="/client-token"), # noqa: S106 + authorization_code=OpenApiOAuthFlow( + authorization_url="/code-authorize", + token_url="/code-token", # noqa: S106 + refresh_url="/refresh", + ), + ) + ), + "oidc": OpenApiOpenIdConnectSecurityScheme(open_id_connect_url="/.well-known/openid-configuration"), + } + + assert serialize_security_schemes(security_schemes) == { + "http.auth": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}, + "api-key": {"type": "apiKey", "name": "X-API-Key", "in": "header"}, + "oauth2": { + "type": "oauth2", + "flows": { + "implicit": {"authorizationUrl": "/authorize", "scopes": {"read": "Read access"}}, + "password": {"tokenUrl": "/token", "scopes": {"write": "Write access"}}, + "clientCredentials": {"tokenUrl": "/client-token", "scopes": {}}, + "authorizationCode": { + "authorizationUrl": "/code-authorize", + "tokenUrl": "/code-token", + "refreshUrl": "/refresh", + "scopes": {}, + }, + }, + }, + "oidc": {"type": "openIdConnect", "openIdConnectUrl": "/.well-known/openid-configuration"}, + } + + +def test_security_schemes_accept_openapi_aliases() -> None: + configuration: typing.Final = SwaggerConfig( + security_schemes={ + "api": {"type": "apiKey", "name": "X-API-Key", "in": "query"}, + "oauth": { + "type": "oauth2", + "flows": {"clientCredentials": {"tokenUrl": "relative-token", "scopes": {}}}, + }, + "oidc": {"type": "openIdConnect", "openIdConnectUrl": "relative-discovery"}, + } + ) + + assert serialize_security_schemes(configuration.security_schemes) == { + "api": {"type": "apiKey", "name": "X-API-Key", "in": "query"}, + "oauth": { + "type": "oauth2", + "flows": {"clientCredentials": {"tokenUrl": "relative-token", "scopes": {}}}, + }, + "oidc": {"type": "openIdConnect", "openIdConnectUrl": "relative-discovery"}, + } + + +def test_oauth_flows_accept_python_and_openapi_password_names() -> None: + flow: typing.Final = OpenApiOAuthFlow(token_url="/token") # noqa: S106 + flows: typing.Final = OpenApiOAuthFlows(resource_owner=flow) + flow_arguments: typing.Final = {"password": flow} + alias_flows: typing.Final = OpenApiOAuthFlows(**flow_arguments) + + assert flows.resource_owner == alias_flows.resource_owner == flow + + +@pytest.mark.parametrize( + ("flows", "error"), + [ + ({"implicit": {"scopes": {}}}, "implicit flow requires authorizationUrl"), + ({"password": {"scopes": {}}}, "password flow requires tokenUrl"), + ({"clientCredentials": {"scopes": {}}}, "client credentials flow requires tokenUrl"), + ( + {"authorizationCode": {"tokenUrl": "/token", "scopes": {}}}, + "authorization code flow requires authorizationUrl", + ), + ( + {"authorizationCode": {"authorizationUrl": "/authorize", "scopes": {}}}, + "authorization code flow requires tokenUrl", + ), + ], +) +def test_oauth_flows_require_urls_for_configured_grant_types(flows: dict[str, object], error: str) -> None: + with pytest.raises(ValidationError, match=error): + OpenApiOAuthFlows.model_validate(flows) + + +def test_oauth_flows_require_at_least_one_grant_type() -> None: + with pytest.raises(ValidationError, match="must configure at least one grant type"): + OpenApiOAuthFlows() + + +@pytest.mark.parametrize("url", ["", " ", "/oauth token", "/oauth\ttoken", "/oauth\ntoken", "/oauth\x00token"]) +def test_oauth_and_openid_urls_reject_empty_whitespace_and_control_characters(url: str) -> None: + with pytest.raises(ValidationError, match="must be non-empty"): + OpenApiOAuthFlow(token_url=url) + with pytest.raises(ValidationError, match="must be non-empty"): + OpenApiOpenIdConnectSecurityScheme(open_id_connect_url=url) + + +def test_oauth_and_openid_urls_allow_relative_references() -> None: + flow: typing.Final = OpenApiOAuthFlow( + authorization_url="authorize", + token_url="../token", # noqa: S106 - a relative URL is under test. + refresh_url="./refresh", + ) + oidc: typing.Final = OpenApiOpenIdConnectSecurityScheme(open_id_connect_url=".well-known/openid-configuration") + + assert flow.authorization_url == "authorize" + assert flow.token_url == "../token" # noqa: S105 - a relative URL is under test. + assert flow.refresh_url == "./refresh" + assert oidc.open_id_connect_url == ".well-known/openid-configuration" + + +@pytest.mark.parametrize( + "configuration", + [ + lambda: OpenApiHttpSecurityScheme.model_validate({"scheme": "bearer", "unexpected": "value"}), + lambda: ApiKeySecurityScheme.model_validate({"name": "X-API-Key", "location": "header", "unexpected": "value"}), + lambda: OpenApiOAuthFlow.model_validate({"tokenUrl": "/token", "unexpected": "value"}), + lambda: OpenApiOAuthFlows.model_validate({"clientCredentials": {"tokenUrl": "/token"}, "unexpected": "value"}), + lambda: OpenApiOAuth2SecurityScheme.model_validate({"flows": {}, "unexpected": "value"}), + lambda: OpenApiOpenIdConnectSecurityScheme.model_validate( + {"openIdConnectUrl": "/discovery", "unexpected": "value"} + ), + ], +) +def test_security_scheme_models_forbid_unknown_fields(configuration: typing.Callable[[], object]) -> None: + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + configuration() + + +@pytest.mark.parametrize("scheme_name", ["", "service auth", "service/auth", "служебная"]) +def test_security_scheme_component_names_must_match_openapi_pattern(scheme_name: str) -> None: + with pytest.raises(ValidationError, match=r"\^\[a-zA-Z0-9\._-\]\+\$"): + SwaggerConfig(security_schemes={scheme_name: OpenApiHttpSecurityScheme(scheme="bearer")}) + + +def test_security_scheme_component_names_allow_openapi_non_identifiers() -> None: + configuration: typing.Final = SwaggerConfig( + security_schemes={"service.auth-1": OpenApiHttpSecurityScheme(scheme="bearer")} + ) + + assert set(configuration.security_schemes) == {"service.auth-1"} diff --git a/tests/instruments/test_opentelemetry.py b/tests/instruments/test_opentelemetry.py index 72dbe36..2870d6b 100644 --- a/tests/instruments/test_opentelemetry.py +++ b/tests/instruments/test_opentelemetry.py @@ -22,6 +22,7 @@ ) from microbootstrap.instruments import opentelemetry_instrument from microbootstrap.instruments.opentelemetry_instrument import BaggageSpanProcessor, OpentelemetryInstrument +from tests.conftest import InMemoryOpenTelemetry def test_opentelemetry_baggage_scope_overrides_removes_and_restores_values() -> None: @@ -178,6 +179,66 @@ def test_opentelemetry_bootstrap_registers_baggage_span_processor( } +def test_opentelemetry_bootstrap_exports_to_in_memory_delivery_boundary( + minimal_opentelemetry_config: OpentelemetryConfig, + in_memory_otel: typing.Any, # noqa: ANN401 +) -> None: + instrument = OpentelemetryInstrument(minimal_opentelemetry_config) + instrument.bootstrap() + + assert in_memory_otel.exporter_calls == [ + ( + (), + { + "endpoint": minimal_opentelemetry_config.opentelemetry_endpoint, + "insecure": minimal_opentelemetry_config.opentelemetry_insecure, + }, + ) + ] + + with instrument.tracer_provider.get_tracer(__name__).start_as_current_span("in-memory-export"): + pass + + assert in_memory_otel.providers[-1].force_flush(timeout_millis=1_000) + assert [span.name for span in in_memory_otel.exporters[-1].get_finished_spans()] == ["in-memory-export"] + + +def test_in_memory_opentelemetry_cleanup_flushes_and_shuts_down_all_owned_providers() -> None: + first_provider: typing.Final = MagicMock(spec=TracerProvider) + second_provider: typing.Final = MagicMock(spec=TracerProvider) + first_provider.force_flush.return_value = True + second_provider.force_flush.return_value = True + telemetry_harness = InMemoryOpenTelemetry(providers=[first_provider, second_provider]) + + telemetry_harness.cleanup() + + first_provider.force_flush.assert_called_once_with(timeout_millis=1_000) + second_provider.force_flush.assert_called_once_with(timeout_millis=1_000) + first_provider.shutdown.assert_called_once_with() + second_provider.shutdown.assert_called_once_with() + + +def test_in_memory_opentelemetry_cleanup_attempts_all_providers_and_surfaces_all_failures() -> None: + first_provider: typing.Final = MagicMock(spec=TracerProvider) + second_provider: typing.Final = MagicMock(spec=TracerProvider) + first_provider.force_flush.side_effect = RuntimeError("first flush failed") + second_provider.force_flush.return_value = False + first_provider.shutdown.side_effect = RuntimeError("first shutdown failed") + second_provider.shutdown.side_effect = RuntimeError("second shutdown failed") + telemetry_harness = InMemoryOpenTelemetry(providers=[first_provider, second_provider]) + + with pytest.raises( + RuntimeError, + match=r"first flush failed.*force_flush returned False.*first shutdown failed.*second shutdown failed", + ): + telemetry_harness.cleanup() + + first_provider.force_flush.assert_called_once_with(timeout_millis=1_000) + second_provider.force_flush.assert_called_once_with(timeout_millis=1_000) + first_provider.shutdown.assert_called_once_with() + second_provider.shutdown.assert_called_once_with() + + def test_opentelemetry_is_ready( minimal_opentelemetry_config: OpentelemetryConfig, ) -> None: @@ -262,11 +323,7 @@ async def test_handler() -> None: assert async_mock.called -def test_fastapi_opentelemetry_bootstrap_working( - minimal_opentelemetry_config: OpentelemetryConfig, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr("opentelemetry.sdk.trace.TracerProvider.shutdown", Mock()) - +def test_fastapi_opentelemetry_bootstrap_working(minimal_opentelemetry_config: OpentelemetryConfig) -> None: test_opentelemetry_instrument: typing.Final = FastApiOpentelemetryInstrument(minimal_opentelemetry_config) test_opentelemetry_instrument.bootstrap() fastapi_application: typing.Final = test_opentelemetry_instrument.bootstrap_after(fastapi.FastAPI()) diff --git a/tests/instruments/test_pyroscope.py b/tests/instruments/test_pyroscope.py index 0c31b27..cd7cce8 100644 --- a/tests/instruments/test_pyroscope.py +++ b/tests/instruments/test_pyroscope.py @@ -18,31 +18,54 @@ pytest.skip("pyroscope is not installed", allow_module_level=True) +@pytest.fixture +def pyroscope_library_boundary( + monkeypatch: pytest.MonkeyPatch, +) -> tuple[Mock, Mock, Mock, Mock]: + configure_mock = Mock() + shutdown_mock = Mock() + add_thread_tag_mock = Mock() + remove_thread_tag_mock = Mock() + monkeypatch.setattr(pyroscope, "configure", configure_mock) + monkeypatch.setattr(pyroscope, "shutdown", shutdown_mock) + monkeypatch.setattr(pyroscope, "add_thread_tag", add_thread_tag_mock) + monkeypatch.setattr(pyroscope, "remove_thread_tag", remove_thread_tag_mock) + return configure_mock, shutdown_mock, add_thread_tag_mock, remove_thread_tag_mock + + class TestPyroscopeInstrument: @pytest.fixture def minimal_pyroscope_config(self) -> PyroscopeConfig: return PyroscopeConfig(pyroscope_endpoint=pydantic.HttpUrl("http://localhost:4040")) - def test_ok(self, minimal_pyroscope_config: PyroscopeConfig) -> None: + def test_ok( + self, + minimal_pyroscope_config: PyroscopeConfig, + pyroscope_library_boundary: tuple[Mock, Mock, Mock, Mock], + ) -> None: + configure_mock, shutdown_mock, _, _ = pyroscope_library_boundary instrument = PyroscopeInstrument(minimal_pyroscope_config) assert instrument.is_ready() instrument.bootstrap() instrument.teardown() + configure_mock.assert_called_once_with( + application_name="micro-service", + server_address="http://localhost:4040/", + sample_rate=100, + tags={}, + ) + shutdown_mock.assert_called_once_with() def test_not_ready(self) -> None: instrument = PyroscopeInstrument(PyroscopeConfig(pyroscope_endpoint=None)) assert not instrument.is_ready() def test_opentelemetry_includes_pyroscope( - self, monkeypatch: pytest.MonkeyPatch, minimal_opentelemetry_config: OpentelemetryConfig + self, + minimal_opentelemetry_config: OpentelemetryConfig, + pyroscope_library_boundary: tuple[Mock, Mock, Mock, Mock], ) -> None: - monkeypatch.setattr("opentelemetry.sdk.trace.TracerProvider.shutdown", Mock()) - monkeypatch.setattr( - "pyroscope.add_thread_tag", add_thread_tag_mock := Mock(side_effect=pyroscope.add_thread_tag) - ) - monkeypatch.setattr( - "pyroscope.remove_thread_tag", remove_thread_tag_mock := Mock(side_effect=pyroscope.remove_thread_tag) - ) + _, _, add_thread_tag_mock, remove_thread_tag_mock = pyroscope_library_boundary minimal_opentelemetry_config.pyroscope_endpoint = pydantic.HttpUrl("http://localhost:4040") diff --git a/tests/instruments/test_swagger.py b/tests/instruments/test_swagger.py index 619d8f5..c9279cb 100644 --- a/tests/instruments/test_swagger.py +++ b/tests/instruments/test_swagger.py @@ -2,15 +2,18 @@ import fastapi import litestar +import pytest from fastapi.testclient import TestClient as FastAPITestClient from litestar import openapi, status_codes from litestar.openapi import spec as litestar_openapi from litestar.openapi.plugins import ScalarRenderPlugin from litestar.static_files import StaticFilesConfig from litestar.testing import TestClient as LitestarTestClient +from pydantic import ValidationError from microbootstrap.bootstrappers.fastapi import FastApiSwaggerInstrument from microbootstrap.bootstrappers.litestar import LitestarSwaggerInstrument +from microbootstrap.instruments.openapi_version_docs import OpenApiOperationSelector, OpenApiVersionDocsConfig from microbootstrap.instruments.swagger_instrument import SwaggerConfig, SwaggerInstrument @@ -37,7 +40,7 @@ def test_swagger_teardown( minimal_swagger_config: SwaggerConfig, ) -> None: swagger_instrument: typing.Final = SwaggerInstrument(minimal_swagger_config) - assert swagger_instrument.teardown() is None # type: ignore[func-returns-value] + swagger_instrument.teardown() def test_litestar_swagger_bootstrap_online_docs(minimal_swagger_config: SwaggerConfig) -> None: @@ -130,6 +133,8 @@ def test_litestar_swagger_bootstrap_working_offline_docs( assert response.status_code == status_codes.HTTP_200_OK response = test_client.get(f"{minimal_swagger_config.service_static_path}/swagger-ui.css") assert response.status_code == status_codes.HTTP_200_OK + response = test_client.get(f"{minimal_swagger_config.service_static_path}/swagger-ui-bundle.js") + assert response.status_code == status_codes.HTTP_200_OK def test_fastapi_swagger_bootstrap_online_docs(minimal_swagger_config: SwaggerConfig) -> None: @@ -172,3 +177,127 @@ def test_fastapi_swagger_bootstrap_working_offline_docs( assert response.status_code == status_codes.HTTP_200_OK response = test_client.get(f"{minimal_swagger_config.service_static_path}/swagger-ui.css") assert response.status_code == status_codes.HTTP_200_OK + response = test_client.get(f"{minimal_swagger_config.service_static_path}/swagger-ui-bundle.js") + assert response.status_code == status_codes.HTTP_200_OK + + +@pytest.mark.parametrize( + ("configuration", "error"), + [ + ( + lambda: OpenApiVersionDocsConfig( + enabled=True, + vendor_media_type="application/vnd.example+json", + ), + "requires at least one supported API version", + ), + ( + lambda: OpenApiVersionDocsConfig(enabled=True, supported_versions=("2026-01",)), + "requires an explicit vendor media type", + ), + ( + lambda: OpenApiVersionDocsConfig( + enabled=True, + vendor_media_type="application/json", + supported_versions=("1.0",), + ), + "Vendor media type must use", + ), + ( + lambda: OpenApiVersionDocsConfig( + enabled=True, + vendor_media_type="application/vnd.example+json", + supported_versions=("1.0", "1.0"), + ), + "must not contain duplicates", + ), + ( + lambda: OpenApiOperationSelector(path="widgets", method="get"), + "must be an absolute path", + ), + ( + lambda: OpenApiOperationSelector(path="/widgets", method="GET"), + "Operation method must be one of", + ), + ], +) +def test_openapi_version_docs_configuration_rejects_invalid_values( + configuration: typing.Callable[[], object], + error: str, +) -> None: + with pytest.raises(ValidationError, match=error): + configuration() + + +@pytest.mark.parametrize( + "vendor_media_type", + [ + "", + "version1.0", + "application/json", + "application/vnd.+json", + "application/vnd.example api+json", + "application/vnd.bad name+json", + "application/vnd.example\tapi+json", + "application/vnd.example\napi+json", + "application/vnd.example\x00api+json", + "application/vnd.example`api+json", + "application/vnd.example,api+json", + "application/vnd.example;api+json", + ], +) +def test_openapi_version_docs_rejects_unsafe_vendor_media_types(vendor_media_type: str) -> None: + with pytest.raises(ValidationError, match="Vendor media type must use"): + OpenApiVersionDocsConfig( + enabled=True, + vendor_media_type=vendor_media_type, + supported_versions=("release-2026",), + ) + + +@pytest.mark.parametrize( + "version", + [ + "", + "1.0 ", + "1.0\tnext", + "1.0\nnext", + "1.0\x00next", + "1.0`next", + "1.0,next", + "1.0;next", + "version1.0, application/json", + ], +) +def test_openapi_version_docs_rejects_unsafe_versions(version: str) -> None: + with pytest.raises(ValidationError, match="safe media-type token"): + OpenApiVersionDocsConfig( + enabled=True, + vendor_media_type="application/vnd.real-api+json", + supported_versions=(version,), + ) + + +def test_openapi_version_docs_accepts_real_vendor_and_non_semver_versions() -> None: + configuration: typing.Final = OpenApiVersionDocsConfig( + enabled=True, + vendor_media_type="application/vnd.real-api_v2+json", + supported_versions=("2026-01", "release-candidate", "v1.0+beta"), + ) + + assert configuration.vendor_media_type == "application/vnd.real-api_v2+json" + assert configuration.supported_versions == ("2026-01", "release-candidate", "v1.0+beta") + + +def test_openapi_version_docs_disabled_default_is_usable() -> None: + configuration: typing.Final = OpenApiVersionDocsConfig() + + assert not configuration.enabled + assert configuration.vendor_media_type is None + assert not configuration.supported_versions + + +def test_openapi_version_docs_disabled_default_survives_model_dump_and_revalidation() -> None: + configuration: typing.Final = OpenApiVersionDocsConfig() + + assert OpenApiVersionDocsConfig.model_validate(configuration.model_dump()) == configuration From 60f650f2c3aee210f5ee7e0ba85f6ac18b674723 Mon Sep 17 00:00:00 2001 From: Mateo Li Date: Wed, 30 Sep 2026 14:46:25 +0300 Subject: [PATCH 02/10] Simplify OpenAPI schema adaptation --- README.md | 6 +- microbootstrap/bootstrappers/fastapi.py | 38 ---- microbootstrap/bootstrappers/litestar.py | 74 ++----- .../test_openapi_version_docs.py | 190 ++++++++++-------- 4 files changed, 137 insertions(+), 171 deletions(-) diff --git a/README.md b/README.md index d3e1524..f0ef896 100644 --- a/README.md +++ b/README.md @@ -682,7 +682,11 @@ existing description. The extension is: `operation_versions` replaces the project version list for its exact path and lower-case method pair. Suppressed pairs remain untouched. A conflicting service-owned `x-accept-versioning` extension raises `ValueError`; an identical one is -idempotent. Configure before the first schema or documentation request. +idempotent. Configure before the first schema or documentation request. For Litestar's standard `Operation`, the +extension is added to an explicit extension-aware operation while preserving its standard fields. A custom Litestar +operation is supported when it explicitly declares an `x-accept-versioning`-aliased dataclass field and can be shallow +copied. Custom operation subclasses without that field are rejected before schema mutation rather than losing custom +state. When enabled, `vendor_media_type` is required explicitly. It must match `application/vnd.+json`, where `` is one or more characters from diff --git a/microbootstrap/bootstrappers/fastapi.py b/microbootstrap/bootstrappers/fastapi.py index c5538b3..a86ef60 100644 --- a/microbootstrap/bootstrappers/fastapi.py +++ b/microbootstrap/bootstrappers/fastapi.py @@ -102,13 +102,6 @@ def documented_openapi() -> dict[str, typing.Any]: return application -def add_version_documentation( - openapi_schema: dict[str, typing.Any], - configuration: OpenApiVersionDocsConfig, -) -> None: - apply_version_documentation(prepare_version_documentation(openapi_schema, configuration)) - - def prepare_version_documentation( openapi_schema: dict[str, typing.Any], configuration: OpenApiVersionDocsConfig, @@ -135,17 +128,6 @@ def apply_version_documentation( operation["description"] = description -def document_operation( - configuration: OpenApiVersionDocsConfig, - path: str, - method: object, - operation: object, -) -> None: - update = prepare_document_operation(configuration, path, method, operation) - if update is not None: - apply_version_documentation((update,)) - - def prepare_document_operation( configuration: OpenApiVersionDocsConfig, path: str, @@ -175,13 +157,6 @@ def prepare_document_operation( return operation, extension, append_version_documentation(description, configuration, supported_versions) -def add_security_schemes( - openapi_schema: dict[str, typing.Any], - configured_schemes: typing.Mapping[str, OpenApiSecurityScheme], -) -> None: - apply_security_schemes(openapi_schema, prepare_security_schemes(openapi_schema, configured_schemes)) - - def prepare_security_schemes( openapi_schema: dict[str, typing.Any], configured_schemes: typing.Mapping[str, OpenApiSecurityScheme], @@ -225,19 +200,6 @@ def apply_security_schemes( security_schemes.update({name: scheme for name, scheme in expected_schemes.items() if name not in security_schemes}) -def add_accept_versioning_extension( - operation: dict[str, typing.Any], - expected_extension: dict[str, str | list[str]], -) -> None: - extension_name: typing.Final = "x-accept-versioning" - if extension_name not in operation: - operation[extension_name] = expected_extension - return - if operation[extension_name] != expected_extension: - message = f"OpenAPI operation {extension_name} conflicts with configured Accept version documentation." - raise ValueError(message) - - @FastApiBootstrapper.use_instrument() class FastApiCorsInstrument(CorsInstrument): def bootstrap_after(self, application: ApplicationT) -> ApplicationT: diff --git a/microbootstrap/bootstrappers/litestar.py b/microbootstrap/bootstrappers/litestar.py index 4211faf..d01bea2 100644 --- a/microbootstrap/bootstrappers/litestar.py +++ b/microbootstrap/bootstrappers/litestar.py @@ -1,4 +1,5 @@ from __future__ import annotations +import copy import dataclasses import typing @@ -57,6 +58,14 @@ ApplicationT = typing.TypeVar("ApplicationT", bound=litestar.Litestar) +@dataclasses.dataclass +class AcceptVersionedOperation(openapi.spec.Operation): + accept_versioning: dict[str, str | list[str]] | None = dataclasses.field( + default=None, + metadata={"alias": "x-accept-versioning"}, + ) + + if typing.TYPE_CHECKING: from litestar.contrib.opentelemetry import OpenTelemetryConfig from litestar.types import ASGIApp, Scope @@ -141,13 +150,6 @@ def bootstrap_after(self, application: ApplicationT) -> ApplicationT: return application -def add_security_schemes( - openapi_schema: openapi.spec.OpenAPI, - configured_schemes: typing.Mapping[str, OpenApiSecurityScheme], -) -> None: - apply_security_schemes(openapi_schema, prepare_security_schemes(openapi_schema, configured_schemes)) - - def prepare_security_schemes( openapi_schema: openapi.spec.OpenAPI, configured_schemes: typing.Mapping[str, OpenApiSecurityScheme], @@ -209,8 +211,8 @@ def prepare_version_documentation( if documented_operation is operation: if description == operation.description: continue - documented_operation = copy_operation(operation) - object.__setattr__(documented_operation, "description", description) + documented_operation = copy.copy(operation) + documented_operation.description = description updates.append((path_item, method, documented_operation)) return updates @@ -283,56 +285,22 @@ def add_accept_versioning_extension( continue existing_extension = getattr(operation, field.name) if existing_extension is None: - versioned_operation = copy_operation(operation) - object.__setattr__(versioned_operation, field.name, expected_extension) + versioned_operation = copy.copy(operation) + setattr(versioned_operation, field.name, expected_extension) return versioned_operation if existing_extension != expected_extension: message = f"OpenAPI operation {extension_name} conflicts with configured Accept version documentation." raise ValueError(message) return operation - operation_type = typing.cast( - "type[openapi.spec.Operation]", - dataclasses.make_dataclass( - cls_name=f"{type(operation).__name__}WithAcceptVersioning", - fields=[ - ( - "accept_versioning", - dict[str, str | list[str]] | None, - dataclasses.field(default=None, metadata={"alias": extension_name}), - ) - ], - bases=(type(operation),), - ), - ) - versioned_operation = copy_operation(operation, operation_type) - object.__setattr__(versioned_operation, "accept_versioning", expected_extension) - return versioned_operation - - -def copy_operation( - operation: openapi.spec.Operation, - operation_type: type[openapi.spec.Operation] | None = None, -) -> openapi.spec.Operation: - copied_operation = object.__new__(operation_type or type(operation)) - copy_instance_state(operation, copied_operation) - return copied_operation - - -def copy_instance_state(source: object, target: object) -> None: - source_dict = getattr(source, "__dict__", None) - target_dict = getattr(target, "__dict__", None) - if isinstance(source_dict, dict) and isinstance(target_dict, dict): - target_dict.update(source_dict) - - for source_class in type(source).__mro__: - slot_names = source_class.__dict__.get("__slots__", ()) - if isinstance(slot_names, str): - slot_names = (slot_names,) - for slot_name in slot_names: - if slot_name in {"__dict__", "__weakref__"} or not hasattr(source, slot_name): - continue - object.__setattr__(target, slot_name, getattr(source, slot_name)) + if type(operation) is not openapi.spec.Operation: + message = ( + f"OpenAPI operation {type(operation).__name__} must declare an {extension_name} alias " + "to use Accept version documentation." + ) + raise TypeError(message) + operation_arguments = {field.name: getattr(operation, field.name) for field in dataclasses.fields(operation)} + return AcceptVersionedOperation(**operation_arguments, accept_versioning=expected_extension) @LitestarBootstrapper.use_instrument() diff --git a/tests/bootstrappers/test_openapi_version_docs.py b/tests/bootstrappers/test_openapi_version_docs.py index 69a582a..08c7bfb 100644 --- a/tests/bootstrappers/test_openapi_version_docs.py +++ b/tests/bootstrappers/test_openapi_version_docs.py @@ -28,7 +28,6 @@ SwaggerConfig, ) from microbootstrap.bootstrappers.fastapi import FastApiBootstrapper, FastApiSwaggerInstrument -from microbootstrap.bootstrappers.fastapi import add_security_schemes as add_fastapi_security_schemes from microbootstrap.bootstrappers.litestar import ( LitestarBootstrapper, LitestarSwaggerInstrument, @@ -577,14 +576,16 @@ def test_fastapi_security_scheme_collisions_are_atomic_and_identical_schemes_are "securitySchemes": {"matching": expected_scheme, "conflicting": {"type": "http", "scheme": "basic"}}, } - with pytest.raises(ValueError, match="security scheme 'conflicting' conflicts"): - add_fastapi_security_schemes( - service_schema, - { + FastApiSwaggerInstrument( + SwaggerConfig( + security_schemes={ "insert": OpenApiApiKeySecurityScheme(name="X-API-Key", location="header"), "conflicting": OpenApiHttpSecurityScheme(scheme="bearer"), - }, + } ) + ).bootstrap_after(application) + with pytest.raises(ValueError, match="security scheme 'conflicting' conflicts"): + application.openapi() assert "insert" not in service_schema["components"]["securitySchemes"] @@ -697,7 +698,7 @@ def test_litestar_version_docs_preserve_service_owned_renderer_hooks_and_late_sc shutdown_hook.assert_called_once_with(application.application) -def test_litestar_version_docs_preserve_custom_operation_subclasses_and_extensions() -> None: +def test_litestar_version_docs_preserve_standard_operation_fields() -> None: expected_extension: typing.Final[dict[str, str | list[str]]] = { "header": "Accept", "mediaType": "application/vnd.real-api+json", @@ -705,108 +706,139 @@ def test_litestar_version_docs_preserve_custom_operation_subclasses_and_extensio "supportedVersions": ["2026-01", "release-candidate"], } - @dataclasses.dataclass(slots=True) - class ServiceMetadataOperation(litestar_openapi.Operation): + application: typing.Final = build_litestar_application(None).application + assert isinstance(application, litestar.Litestar) + assert application.openapi_schema is not None + assert application.openapi_schema.paths is not None + path_item: typing.Final = application.openapi_schema.paths[TARGET_PATH] + assert isinstance(path_item, litestar_openapi.PathItem) + assert path_item.get is not None + standard_operation: typing.Final = path_item.get + standard_fields: typing.Final = { + field.name: getattr(standard_operation, field.name) + for field in dataclasses.fields(litestar_openapi.Operation) + if field.name != "description" + } + + instrument: typing.Final = LitestarSwaggerInstrument( + SwaggerConfig(openapi_version_docs=build_version_docs_config()) + ) + instrument.bootstrap_after(application) + + documented_operation: typing.Final = path_item.get + assert documented_operation is not standard_operation + assert all(getattr(documented_operation, name) == value for name, value in standard_fields.items()) + assert documented_operation.to_schema()["x-accept-versioning"] == expected_extension + + instrument.bootstrap_after(application) + assert path_item.get is documented_operation + + canonical_schema: typing.Final = application.openapi_schema.to_schema() + with LitestarTestClient(app=application) as test_client: + served_schema: typing.Final = test_client.get("/schema/openapi.json") + + assert canonical_schema["paths"][TARGET_PATH]["get"]["x-accept-versioning"] == expected_extension + assert served_schema.status_code == status_codes.HTTP_200_OK + assert served_schema.json()["paths"][TARGET_PATH]["get"]["x-accept-versioning"] == expected_extension + + +def test_litestar_version_docs_copy_custom_aliased_operation() -> None: + expected_extension: typing.Final[dict[str, str | list[str]]] = { + "header": "Accept", + "mediaType": "application/vnd.real-api+json", + "parameter": "version", + "supportedVersions": ["2026-01", "release-candidate"], + } + + @dataclasses.dataclass + class ServiceVersionedOperation(litestar_openapi.Operation): service_metadata: dict[str, str] | None = dataclasses.field( default=None, metadata={"alias": "x-service-metadata"}, ) - rendering_state: str = dataclasses.field(init=False, default="draft") - - def service_owner(self) -> str | None: - if self.service_metadata is None: - return None - return self.service_metadata["owner"] - - def to_schema(self) -> dict[str, typing.Any]: - return {**super(ServiceMetadataOperation, self).to_schema(), "x-rendering-state": self.rendering_state} - - @dataclasses.dataclass(slots=True) - class ServiceVersionedOperation(ServiceMetadataOperation): accept_versioning: dict[str, str | list[str]] | None = dataclasses.field( default=None, metadata={"alias": "x-accept-versioning"}, ) + rendering_state: str = dataclasses.field(init=False, default="draft") - def custom_litestar_operation_generator( - operation: litestar_openapi.Operation, - accept_versioning: dict[str, str | list[str]] | None = None, - ) -> ServiceMetadataOperation: - operation_fields: typing.Final = { - field.name: getattr(operation, field.name) - for field in dataclasses.fields(operation) - if field.name not in {"service_metadata", "accept_versioning", "rendering_state"} - } - if accept_versioning is None: - return ServiceMetadataOperation( - **operation_fields, - service_metadata={"owner": "widgets"}, - ) - return ServiceVersionedOperation( - **operation_fields, - service_metadata={"owner": "widgets"}, - accept_versioning=accept_versioning, - ) - - @get(TARGET_PATH, description=GET_DESCRIPTION) - async def list_service_widgets() -> dict[str, str]: - return {"status": "ok"} + def to_schema(self) -> dict[str, typing.Any]: + return {**super().to_schema(), "x-rendering-state": self.rendering_state} - application: typing.Final = litestar.Litestar(route_handlers=[list_service_widgets]) + application: typing.Final = build_litestar_application(None).application + assert isinstance(application, litestar.Litestar) assert application.openapi_schema is not None assert application.openapi_schema.paths is not None path_item: typing.Final = application.openapi_schema.paths[TARGET_PATH] assert isinstance(path_item, litestar_openapi.PathItem) assert path_item.get is not None - service_owned_operation: typing.Final[ServiceVersionedOperation] = typing.cast( - "ServiceVersionedOperation", - custom_litestar_operation_generator(path_item.get, {}), + operation_fields: typing.Final = { + field.name: getattr(path_item.get, field.name) for field in dataclasses.fields(litestar_openapi.Operation) + } + service_owned_operation: typing.Final = ServiceVersionedOperation( + **operation_fields, + service_metadata={"owner": "widgets"}, ) - service_owned_operation.accept_versioning = None service_owned_operation.rendering_state = "published" path_item.get = service_owned_operation - pre_adaptation_schema: typing.Final = service_owned_operation.to_schema() - assert pre_adaptation_schema["x-service-metadata"] == {"owner": "widgets"} - assert pre_adaptation_schema["x-rendering-state"] == "published" - assert "x-accept-versioning" not in pre_adaptation_schema - instrument: typing.Final = LitestarSwaggerInstrument( - SwaggerConfig(openapi_version_docs=build_version_docs_config()) + LitestarSwaggerInstrument(SwaggerConfig(openapi_version_docs=build_version_docs_config())).bootstrap_after( + application ) - instrument.bootstrap_after(application) documented_operation: typing.Final = path_item.get assert type(documented_operation) is ServiceVersionedOperation - assert documented_operation.service_owner() == "widgets" - assert documented_operation.to_schema()["x-service-metadata"] == {"owner": "widgets"} - assert documented_operation.to_schema()["x-rendering-state"] == "published" + assert documented_operation.service_metadata == {"owner": "widgets"} + assert documented_operation.rendering_state == "published" assert documented_operation.to_schema()["x-accept-versioning"] == expected_extension - instrument.bootstrap_after(application) - assert path_item.get is documented_operation - assert documented_operation.rendering_state == "published" + assert add_accept_versioning_extension(documented_operation, expected_extension) is documented_operation + conflicting_operation: typing.Final = copy.copy(documented_operation) + conflicting_operation.accept_versioning = {**expected_extension, "header": "X-Service-Version"} + with pytest.raises(ValueError, match="x-accept-versioning conflicts"): + add_accept_versioning_extension(conflicting_operation, expected_extension) - canonical_schema: typing.Final = application.openapi_schema.to_schema() - with LitestarTestClient(app=application) as test_client: - served_schema: typing.Final = test_client.get("/schema/openapi.json") - assert canonical_schema["paths"][TARGET_PATH]["get"]["x-service-metadata"] == {"owner": "widgets"} - assert canonical_schema["paths"][TARGET_PATH]["get"]["x-rendering-state"] == "published" - assert canonical_schema["paths"][TARGET_PATH]["get"]["x-accept-versioning"] == expected_extension - assert served_schema.status_code == status_codes.HTTP_200_OK - assert served_schema.json()["paths"][TARGET_PATH]["get"]["x-service-metadata"] == {"owner": "widgets"} - assert served_schema.json()["paths"][TARGET_PATH]["get"]["x-rendering-state"] == "published" - assert served_schema.json()["paths"][TARGET_PATH]["get"]["x-accept-versioning"] == expected_extension +def test_litestar_version_docs_reject_unsupported_custom_operation_atomically() -> None: + @dataclasses.dataclass + class ServiceMetadataOperation(litestar_openapi.Operation): + service_metadata: dict[str, str] | None = dataclasses.field( + default=None, + metadata={"alias": "x-service-metadata"}, + ) - matching_operation: typing.Final = custom_litestar_operation_generator(service_owned_operation, expected_extension) - assert add_accept_versioning_extension(matching_operation, expected_extension) is matching_operation + application: typing.Final = build_litestar_application(None).application + assert isinstance(application, litestar.Litestar) + assert application.openapi_schema is not None + assert application.openapi_schema.paths is not None + path_item: typing.Final = application.openapi_schema.paths[TARGET_PATH] + assert isinstance(path_item, litestar_openapi.PathItem) + assert path_item.post is not None + original_get: typing.Final = path_item.get + operation_fields: typing.Final = { + field.name: getattr(path_item.post, field.name) for field in dataclasses.fields(litestar_openapi.Operation) + } + unsupported_operation: typing.Final = ServiceMetadataOperation( + **operation_fields, + service_metadata={"owner": "widgets"}, + ) + path_item.post = unsupported_operation + baseline_schema: typing.Final = copy.deepcopy(application.openapi_schema.to_schema()) - conflicting_operation: typing.Final = custom_litestar_operation_generator( - service_owned_operation, - {**expected_extension, "header": "X-Service-Version"}, + instrument: typing.Final = LitestarSwaggerInstrument( + SwaggerConfig( + security_schemes={"serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer")}, + openapi_version_docs=build_version_docs_config(), + ) ) - with pytest.raises(ValueError, match="x-accept-versioning conflicts"): - add_accept_versioning_extension(conflicting_operation, expected_extension) + with pytest.raises(TypeError, match="must declare an x-accept-versioning alias"): + instrument.bootstrap_after(application) + + assert application.openapi_schema.to_schema() == baseline_schema + assert path_item.get is original_get + assert path_item.post is unsupported_operation + assert application.openapi_schema.components.security_schemes is not None + assert "serviceAuth" not in application.openapi_schema.components.security_schemes def test_fastapi_production_version_docs_preserve_custom_generator_and_errors() -> None: From 153f44b623f45b7ee8d81a71f0e45e85d4ca8601 Mon Sep 17 00:00:00 2001 From: Mateo Li Date: Wed, 30 Sep 2026 15:23:06 +0300 Subject: [PATCH 03/10] Refactor shared OpenAPI policy into Swagger instruments --- README.md | 121 ++----- microbootstrap/bootstrappers/fastapi.py | 194 +++++------ microbootstrap/bootstrappers/litestar.py | 328 +++++++++--------- .../instruments/openapi_version_docs.py | 57 --- .../instruments/swagger_instrument.py | 116 ++++++- .../test_openapi_version_docs.py | 64 +++- 6 files changed, 446 insertions(+), 434 deletions(-) diff --git a/README.md b/README.md index f0ef896..cc515a3 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ With microbootstrap, you receive an application with lightweight built-in - `opentelemetry` - `logging` - `cors` -- `swagger` - with additional offline version support +- `swagger` - offline UI assets, OpenAPI security definitions, and optional Accept-version documentation - `health-checks` Those instruments can be bootstrapped for: @@ -81,20 +81,6 @@ Also, you can specify extras during installation for concrete framework: - `faststream` (ASGI app) - `fastmcp` -The `fastapi`, `litestar`, and `faststream` bounds are support-policy bounds based on the evidence below, not -mathematical minimum versions or certification of every version in each declared range, every dependency combination, -or every supported Python version. - -A full combined selected-framework-floors suite passed 308 tests on Python 3.12.7 with FastAPI 0.110.1, -prometheus-fastapi-instrumentator 7.1.0, Litestar 2.21.1, and FastStream 0.6.7. A separate fresh frozen installation -using uv 0.10 passed 308 tests, Ruff check, Ruff format check, and mypy with an exact resolved graph including FastAPI -0.141.1, prometheus-fastapi-instrumentator 8.1.0, Litestar 2.24.0, and FastStream 0.6.7. The ignored lock file is -local validation evidence and is not included in this PR. These runs exclude the brokerless AsyncAPI case and do not -certify future releases, platform variants, multi-broker configurations, external transports, or OTLP delivery. - -An application without a broker is not required to deliver AsyncAPI documentation and is not part of the OpenAPI -delivery contract. - Also we have `granian` extra that is requires for `create_granian_server`. For uv: @@ -610,94 +596,49 @@ Parameter descriptions: #### Optional security schemes and API-version documentation -Both OpenAPI additions are opt-in. `SwaggerConfig.security_schemes` adds reusable definitions to -`components.securitySchemes`; it does not add global or operation-level security requirements. -`OpenApiVersionDocsConfig` documents a media-type convention only; it does not negotiate requests, add an `Accept` -parameter, change response media types, or select a version in Swagger UI. +Both additions are disabled by default. Security schemes add reusable OpenAPI definitions under +`components.securitySchemes`; they do not authenticate requests or add global or operation-level security requirements. +Keep requirements and authentication in your application routes and dependencies. ```python from microbootstrap import ( - OpenApiApiKeySecurityScheme as ApiKeySecurityScheme, + LitestarSettings, OpenApiHttpSecurityScheme, OpenApiOperationSelector, OpenApiOperationVersionOverride, - OpenApiOAuth2SecurityScheme, - OpenApiOAuthFlow, - OpenApiOAuthFlows, - OpenApiOpenIdConnectSecurityScheme, + OpenApiSecurityScheme, OpenApiVersionDocsConfig, - SwaggerConfig, ) -version_docs = OpenApiVersionDocsConfig( - enabled=True, - vendor_media_type="application/vnd.example+json", - supported_versions=("1.0",), - suppressed_operations=(OpenApiOperationSelector(path="/internal/widgets", method="get"),), - operation_versions=( - OpenApiOperationVersionOverride(path="/widgets", method="post", supported_versions=("2.0",)), - ), -) -application = ( - LitestarBootstrapper(settings) - .configure_instrument( - SwaggerConfig( - security_schemes={ - "serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer", bearer_format="JWT"), - "apiKey": ApiKeySecurityScheme(name="X-API-Key", location="header"), - "oauth": OpenApiOAuth2SecurityScheme( - flows=OpenApiOAuthFlows( - client_credentials=OpenApiOAuthFlow(token_url="/oauth/token", scopes={"read": "Read data"}) - ) - ), - "oidc": OpenApiOpenIdConnectSecurityScheme( - open_id_connect_url="/.well-known/openid-configuration" - ), - }, - openapi_version_docs=version_docs, - ) +class Settings(LitestarSettings): + security_schemes: dict[str, OpenApiSecurityScheme] = { + "serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer", bearer_format="JWT"), + } + openapi_version_docs: OpenApiVersionDocsConfig | None = OpenApiVersionDocsConfig( + enabled=True, + vendor_media_type="application/vnd.example+json", + supported_versions=("1.0",), + suppressed_operations=(OpenApiOperationSelector(path="/internal/widgets", method="get"),), + operation_versions=( + OpenApiOperationVersionOverride(path="/widgets", method="post", supported_versions=("2.0",)), + ), ) - .bootstrap() -) ``` -The supported typed definitions are HTTP, API key, OAuth 2.0, and OpenID Connect. Python field names and OpenAPI -aliases are accepted; serialized schemas use canonical OpenAPI names such as `bearerFormat`, `in`, `tokenUrl`, and -`openIdConnectUrl`. OAuth flow and OpenID Connect URLs may be relative, but must be non-empty and contain no whitespace -or control characters. OAuth 2.0 configuration must declare at least one flow. A matching service-owned scheme is retained, while a same-named -conflicting definition raises `ValueError`. - -Each non-suppressed documented operation receives an `x-accept-versioning` extension and matching text appended to its -existing description. The extension is: - -```json -{ - "header": "Accept", - "mediaType": "application/vnd.example+json", - "parameter": "version", - "supportedVersions": ["1.0"] -} -``` - -`operation_versions` replaces the project version list for its exact path and lower-case method pair. Suppressed pairs -remain untouched. A conflicting service-owned `x-accept-versioning` extension raises `ValueError`; an identical one is -idempotent. Configure before the first schema or documentation request. For Litestar's standard `Operation`, the -extension is added to an explicit extension-aware operation while preserving its standard fields. A custom Litestar -operation is supported when it explicitly declares an `x-accept-versioning`-aliased dataclass field and can be shallow -copied. Custom operation subclasses without that field are rejected before schema mutation rather than losing custom -state. - -When enabled, `vendor_media_type` is required explicitly. It must match -`application/vnd.+json`, where `` is one or more characters from -`!#$%&'*+-.^_|~0-9A-Za-z`. Backticks are intentionally excluded from the HTTP token set because values are rendered in -Markdown code spans. Each `supported_versions` value uses the same non-empty grammar and is not constrained to a -numeric or semantic-version format. Whitespace, control characters, commas, semicolons, and values outside that grammar -are rejected; values are never trimmed or escaped. - -For Litestar, the bootstrapper updates the canonical OpenAPI model; for FastAPI, it composes the public `app.openapi` -callable. Custom components, renderers, generators, schema caching, and errors are retained. Exporters should use that -same canonical schema. +HTTP, API key, OAuth 2.0, and OpenID Connect definitions are supported. Python field names and OpenAPI aliases are +accepted; output uses canonical names such as `bearerFormat`, `in`, `tokenUrl`, and `openIdConnectUrl`. A same-named +definition must be identical to the service-owned definition or schema generation raises `ValueError`. + +Version documentation is a fixed Accept-media-type convention. Each non-suppressed operation gets an +`x-accept-versioning` extension and matching description text; `operation_versions` replaces the project version list +for one exact path and lower-case HTTP method. It does not negotiate requests, add an `Accept` parameter, change response +media types, or provide a Swagger UI version selector. + +For Litestar, custom `Operation` subclasses must explicitly declare an `x-accept-versioning`-aliased dataclass field; +unsupported subclasses are rejected rather than losing custom state. For FastAPI, configure documentation before the +first OpenAPI request. The original `app.openapi` generator and its cache remain in use, so after correcting a +service-owned schema conflict, requesting the schema again applies the configured documentation to that cached schema. #### FastStream AsyncAPI documentation diff --git a/microbootstrap/bootstrappers/fastapi.py b/microbootstrap/bootstrappers/fastapi.py index a86ef60..8f00f54 100644 --- a/microbootstrap/bootstrappers/fastapi.py +++ b/microbootstrap/bootstrappers/fastapi.py @@ -12,15 +12,6 @@ from microbootstrap.instruments.cors_instrument import CorsInstrument from microbootstrap.instruments.health_checks_instrument import HealthChecksInstrument, HealthCheckTypedDict from microbootstrap.instruments.logging_instrument import LoggingInstrument -from microbootstrap.instruments.openapi_security_schemes import OpenApiSecurityScheme, serialize_security_schemes -from microbootstrap.instruments.openapi_version_docs import ( - SUPPORTED_HTTP_METHODS, - OpenApiVersionDocsConfig, - append_version_documentation, - build_accept_versioning_extension, - get_supported_versions, - is_operation_suppressed, -) from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument from microbootstrap.instruments.prometheus_instrument import FastApiPrometheusConfig, PrometheusInstrument from microbootstrap.instruments.pyroscope_instrument import PyroscopeInstrument @@ -76,128 +67,99 @@ def bootstrap_before(self) -> dict[str, typing.Any]: def bootstrap_after(self, application: ApplicationT) -> ApplicationT: if self.instrument_config.swagger_offline_docs: enable_offline_docs(application, static_files_handler=self.instrument_config.service_static_path) - version_docs_config: typing.Final = self.instrument_config.openapi_version_docs - security_schemes: typing.Final = self.instrument_config.security_schemes - version_docs_enabled: typing.Final = version_docs_config is not None and version_docs_config.enabled - if not version_docs_enabled and not security_schemes: + if not self._has_version_documentation() and not self.instrument_config.security_schemes: return application original_openapi: typing.Final = application.openapi def documented_openapi() -> dict[str, typing.Any]: openapi_schema: typing.Final = original_openapi() - expected_schemes: dict[str, dict[str, typing.Any]] | None = None - if security_schemes: - expected_schemes = prepare_security_schemes(openapi_schema, security_schemes) - version_documentation: list[tuple[dict[str, typing.Any], dict[str, str | list[str]], str]] = [] - if version_docs_enabled: - assert version_docs_config is not None # noqa: S101 - checked above. - version_documentation = prepare_version_documentation(openapi_schema, version_docs_config) + expected_schemes: typing.Final = self._prepare_security_scheme_updates(openapi_schema) + version_documentation: typing.Final = self._prepare_version_documentation_updates(openapi_schema) if expected_schemes is not None: - apply_security_schemes(openapi_schema, expected_schemes) - apply_version_documentation(version_documentation) + self._apply_security_scheme_updates(openapi_schema, expected_schemes) + self._apply_version_documentation_updates(version_documentation) return openapi_schema application.openapi = documented_openapi # type: ignore[method-assign] # FastAPI's public custom OpenAPI hook. return application + def _prepare_security_scheme_updates( + self, + openapi_schema: dict[str, typing.Any], + ) -> dict[str, dict[str, typing.Any]] | None: + if not self.instrument_config.security_schemes: + return None + expected_schemes: typing.Final = self._expected_security_schemes() + components = openapi_schema.get("components") + if components is None: + return expected_schemes + if not isinstance(components, dict): + message = "OpenAPI components must be a dictionary to configure security schemes." + raise TypeError(message) + security_schemes = components.get("securitySchemes") + if security_schemes is None: + return expected_schemes + if not isinstance(security_schemes, dict): + message = "OpenAPI components.securitySchemes must be a dictionary to configure security schemes." + raise TypeError(message) + self._validate_security_scheme_conflicts(security_schemes) + return expected_schemes + + @staticmethod + def _apply_security_scheme_updates( + openapi_schema: dict[str, typing.Any], + expected_schemes: dict[str, dict[str, typing.Any]], + ) -> None: + components = openapi_schema.get("components") + if components is None: + openapi_schema["components"] = {"securitySchemes": expected_schemes} + return + assert isinstance(components, dict) # noqa: S101 - validated before application. + security_schemes = components.get("securitySchemes") + if security_schemes is None: + components["securitySchemes"] = expected_schemes + return + assert isinstance(security_schemes, dict) # noqa: S101 - validated before application. + security_schemes.update( + {name: scheme for name, scheme in expected_schemes.items() if name not in security_schemes} + ) -def prepare_version_documentation( - openapi_schema: dict[str, typing.Any], - configuration: OpenApiVersionDocsConfig, -) -> list[tuple[dict[str, typing.Any], dict[str, str | list[str]], str]]: - updates: list[tuple[dict[str, typing.Any], dict[str, str | list[str]], str]] = [] - paths = openapi_schema.get("paths") - if not isinstance(paths, dict): + def _prepare_version_documentation_updates( + self, + openapi_schema: dict[str, typing.Any], + ) -> list[tuple[dict[str, typing.Any], dict[str, str | list[str]], str]]: + updates: list[tuple[dict[str, typing.Any], dict[str, str | list[str]], str]] = [] + if not self._has_version_documentation(): + return updates + paths = openapi_schema.get("paths") + if not isinstance(paths, dict): + return updates + for path, path_item in paths.items(): + if not isinstance(path, str) or not isinstance(path_item, dict): + continue + for method, operation in path_item.items(): + if not isinstance(method, str) or not isinstance(operation, dict): + continue + documentation = self._prepare_version_documentation( + path, + method, + operation.get("description"), + operation.get("x-accept-versioning"), + has_existing_extension="x-accept-versioning" in operation, + ) + if documentation is not None: + extension, description = documentation + updates.append((operation, extension, description)) return updates - for path, path_item in paths.items(): - if not isinstance(path, str) or not isinstance(path_item, dict): - continue - for method, operation in path_item.items(): - update = prepare_document_operation(configuration, path, method, operation) - if update is not None: - updates.append(update) - return updates - - -def apply_version_documentation( - updates: typing.Iterable[tuple[dict[str, typing.Any], dict[str, str | list[str]], str]], -) -> None: - for operation, extension, description in updates: - operation["x-accept-versioning"] = extension - operation["description"] = description - - -def prepare_document_operation( - configuration: OpenApiVersionDocsConfig, - path: str, - method: object, - operation: object, -) -> tuple[dict[str, typing.Any], dict[str, str | list[str]], str] | None: - if ( - not isinstance(method, str) - or not isinstance(operation, dict) - or method not in SUPPORTED_HTTP_METHODS - or is_operation_suppressed(configuration, path, method) - ): - return None - supported_versions = get_supported_versions(configuration, path, method) - extension = build_accept_versioning_extension(configuration, supported_versions) - existing_extension = operation.get("x-accept-versioning") - if existing_extension is not None and existing_extension != extension: - message = "OpenAPI operation x-accept-versioning conflicts with configured Accept version documentation." - raise ValueError(message) - if "x-accept-versioning" in operation and existing_extension is None: - message = "OpenAPI operation x-accept-versioning conflicts with configured Accept version documentation." - raise ValueError(message) - description = operation.get("description") - if description is not None and not isinstance(description, str): - message = f"OpenAPI operation {method.upper()} {path} has a non-string description." - raise ValueError(message) - return operation, extension, append_version_documentation(description, configuration, supported_versions) - - -def prepare_security_schemes( - openapi_schema: dict[str, typing.Any], - configured_schemes: typing.Mapping[str, OpenApiSecurityScheme], -) -> dict[str, dict[str, typing.Any]]: - expected_schemes: typing.Final = serialize_security_schemes(configured_schemes) - components = openapi_schema.get("components") - if components is None: - return expected_schemes - if not isinstance(components, dict): - message = "OpenAPI components must be a dictionary to configure security schemes." - raise TypeError(message) - security_schemes = components.get("securitySchemes") - if security_schemes is None: - return expected_schemes - if not isinstance(security_schemes, dict): - message = "OpenAPI components.securitySchemes must be a dictionary to configure security schemes." - raise TypeError(message) - - for scheme_name, expected_scheme in expected_schemes.items(): - if scheme_name in security_schemes and security_schemes[scheme_name] != expected_scheme: - message = f"OpenAPI security scheme '{scheme_name}' conflicts with the configured security scheme." - raise ValueError(message) - return expected_schemes - - -def apply_security_schemes( - openapi_schema: dict[str, typing.Any], - expected_schemes: dict[str, dict[str, typing.Any]], -) -> None: - components = openapi_schema.get("components") - if components is None: - openapi_schema["components"] = {"securitySchemes": expected_schemes} - return - assert isinstance(components, dict) # noqa: S101 - validated before application. - security_schemes = components.get("securitySchemes") - if security_schemes is None: - components["securitySchemes"] = expected_schemes - return - assert isinstance(security_schemes, dict) # noqa: S101 - validated before application. - security_schemes.update({name: scheme for name, scheme in expected_schemes.items() if name not in security_schemes}) + @staticmethod + def _apply_version_documentation_updates( + updates: typing.Iterable[tuple[dict[str, typing.Any], dict[str, str | list[str]], str]], + ) -> None: + for operation, extension, description in updates: + operation["x-accept-versioning"] = extension + operation["description"] = description @FastApiBootstrapper.use_instrument() diff --git a/microbootstrap/bootstrappers/litestar.py b/microbootstrap/bootstrappers/litestar.py index d01bea2..1415aa1 100644 --- a/microbootstrap/bootstrappers/litestar.py +++ b/microbootstrap/bootstrappers/litestar.py @@ -36,13 +36,7 @@ OpenApiOpenIdConnectSecurityScheme, OpenApiSecurityScheme, ) -from microbootstrap.instruments.openapi_version_docs import ( - OpenApiVersionDocsConfig, - append_version_documentation, - build_accept_versioning_extension, - get_supported_versions, - is_operation_suppressed, -) +from microbootstrap.instruments.openapi_version_docs import SUPPORTED_HTTP_METHODS from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument from microbootstrap.instruments.prometheus_instrument import ( LitestarPrometheusConfig, @@ -132,175 +126,193 @@ def bootstrap_before(self) -> dict[str, typing.Any]: return bootstrap_result def bootstrap_after(self, application: ApplicationT) -> ApplicationT: - version_docs_config: typing.Final = self.instrument_config.openapi_version_docs - security_schemes: typing.Final = self.instrument_config.security_schemes if ( - (version_docs_config is None or not version_docs_config.enabled) and not security_schemes + not self._has_version_documentation() and not self.instrument_config.security_schemes ) or application.openapi_schema is None: return application - expected_schemes: dict[str, openapi.spec.SecurityScheme] | None = None - if security_schemes: - expected_schemes = prepare_security_schemes(application.openapi_schema, security_schemes) - version_documentation: list[tuple[openapi.spec.PathItem, str, openapi.spec.Operation]] = [] - if version_docs_config is not None and version_docs_config.enabled: - version_documentation = prepare_version_documentation(application.openapi_schema, version_docs_config) + expected_schemes: typing.Final = self._prepare_security_scheme_updates(application.openapi_schema) + version_documentation: typing.Final = self._prepare_version_documentation_updates(application.openapi_schema) if expected_schemes is not None: - apply_security_schemes(application.openapi_schema, expected_schemes) - apply_version_documentation(version_documentation) + self._apply_security_scheme_updates(application.openapi_schema, expected_schemes) + self._apply_version_documentation_updates(version_documentation) return application + def _prepare_security_scheme_updates( + self, + openapi_schema: openapi.spec.OpenAPI, + ) -> dict[str, openapi.spec.SecurityScheme] | None: + if not self.instrument_config.security_schemes: + return None + security_schemes = openapi_schema.components.security_schemes + if security_schemes is not None: + canonical_schemes: typing.Final = { + name: scheme.to_schema() if isinstance(scheme, openapi.spec.SecurityScheme) else scheme + for name, scheme in security_schemes.items() + } + self._validate_security_scheme_conflicts(canonical_schemes) + return { + scheme_name: self._build_litestar_security_scheme(security_scheme) + for scheme_name, security_scheme in self.instrument_config.security_schemes.items() + } -def prepare_security_schemes( - openapi_schema: openapi.spec.OpenAPI, - configured_schemes: typing.Mapping[str, OpenApiSecurityScheme], -) -> dict[str, openapi.spec.SecurityScheme]: - expected_schemes: typing.Final = { - scheme_name: build_litestar_security_scheme(security_scheme) - for scheme_name, security_scheme in configured_schemes.items() - } - security_schemes = openapi_schema.components.security_schemes - if security_schemes is None: - return expected_schemes - - for scheme_name, expected_scheme in expected_schemes.items(): - existing_scheme = security_schemes.get(scheme_name) - if existing_scheme is not None and ( - not isinstance(existing_scheme, openapi.spec.SecurityScheme) - or existing_scheme.to_schema() != expected_scheme.to_schema() - ): - message = f"OpenAPI security scheme '{scheme_name}' conflicts with the configured security scheme." - raise ValueError(message) - return expected_schemes - - -def apply_security_schemes( - openapi_schema: openapi.spec.OpenAPI, - expected_schemes: dict[str, openapi.spec.SecurityScheme], -) -> None: - security_schemes = openapi_schema.components.security_schemes - if security_schemes is None: - openapi_schema.components.security_schemes = typing.cast( - "dict[str, openapi.spec.SecurityScheme | openapi.spec.Reference]", - expected_schemes, + @staticmethod + def _apply_security_scheme_updates( + openapi_schema: openapi.spec.OpenAPI, + expected_schemes: dict[str, openapi.spec.SecurityScheme], + ) -> None: + security_schemes = openapi_schema.components.security_schemes + if security_schemes is None: + openapi_schema.components.security_schemes = typing.cast( + "dict[str, openapi.spec.SecurityScheme | openapi.spec.Reference]", + expected_schemes, + ) + return + security_schemes.update( + {name: scheme for name, scheme in expected_schemes.items() if name not in security_schemes} ) - return - security_schemes.update({name: scheme for name, scheme in expected_schemes.items() if name not in security_schemes}) - -def prepare_version_documentation( - openapi_schema: openapi.spec.OpenAPI, - configuration: OpenApiVersionDocsConfig, -) -> list[tuple[openapi.spec.PathItem, str, openapi.spec.Operation]]: - updates: list[tuple[openapi.spec.PathItem, str, openapi.spec.Operation]] = [] - if openapi_schema.paths is None: - return updates - for path, path_item in openapi_schema.paths.items(): - if not isinstance(path_item, openapi.spec.PathItem): - continue - for method in ("delete", "get", "head", "options", "patch", "post", "put", "trace"): - operation = getattr(path_item, method) - if operation is None or is_operation_suppressed(configuration, path, method): + def _prepare_version_documentation_updates( + self, + openapi_schema: openapi.spec.OpenAPI, + ) -> list[tuple[openapi.spec.PathItem, str, openapi.spec.Operation]]: + updates: list[tuple[openapi.spec.PathItem, str, openapi.spec.Operation]] = [] + if not self._has_version_documentation() or openapi_schema.paths is None: + return updates + for path, path_item in openapi_schema.paths.items(): + if not isinstance(path_item, openapi.spec.PathItem): continue - if operation.description is not None and not isinstance(operation.description, str): - message = f"OpenAPI operation {method.upper()} {path} has a non-string description." - raise ValueError(message) - supported_versions = get_supported_versions(configuration, path, method) - extension = build_accept_versioning_extension(configuration, supported_versions) - description = append_version_documentation(operation.description, configuration, supported_versions) - documented_operation = add_accept_versioning_extension(operation, extension) - if documented_operation is operation: - if description == operation.description: + for method in SUPPORTED_HTTP_METHODS: + operation = getattr(path_item, method) + if operation is None: continue - documented_operation = copy.copy(operation) - documented_operation.description = description - updates.append((path_item, method, documented_operation)) - return updates - - -def apply_version_documentation( - updates: typing.Iterable[tuple[openapi.spec.PathItem, str, openapi.spec.Operation]], -) -> None: - for path_item, method, operation in updates: - setattr(path_item, method, operation) - - -def build_litestar_security_scheme(security_scheme: OpenApiSecurityScheme) -> openapi.spec.SecurityScheme: - if isinstance(security_scheme, OpenApiHttpSecurityScheme): - return openapi.spec.SecurityScheme( - type=security_scheme.type, - scheme=security_scheme.scheme, - bearer_format=security_scheme.bearer_format, - description=security_scheme.description, - ) - if isinstance(security_scheme, OpenApiApiKeySecurityScheme): - return openapi.spec.SecurityScheme( - type=security_scheme.type, - name=security_scheme.name, - security_scheme_in=security_scheme.location, - description=security_scheme.description, - ) - if isinstance(security_scheme, OpenApiOAuth2SecurityScheme): - return openapi.spec.SecurityScheme( - type=security_scheme.type, - flows=build_litestar_oauth_flows(security_scheme.flows), - description=security_scheme.description, - ) - if isinstance(security_scheme, OpenApiOpenIdConnectSecurityScheme): - return openapi.spec.SecurityScheme( - type=security_scheme.type, - open_id_connect_url=security_scheme.open_id_connect_url, - description=security_scheme.description, - ) - raise AssertionError("Unsupported OpenAPI security scheme.") - - -def build_litestar_oauth_flows(oauth_flows: OpenApiOAuthFlows) -> openapi.spec.OAuthFlows: - flow_arguments: typing.Final[dict[str, typing.Any]] = { - "implicit": build_litestar_oauth_flow(oauth_flows.implicit), - "password": build_litestar_oauth_flow(oauth_flows.resource_owner), - "client_credentials": build_litestar_oauth_flow(oauth_flows.client_credentials), - "authorization_code": build_litestar_oauth_flow(oauth_flows.authorization_code), - } - return openapi.spec.OAuthFlows(**flow_arguments) - + documentation = self._prepare_version_documentation( + path, + method, + operation.description, + self._read_accept_versioning_extension(operation), + has_existing_extension=self._has_accept_versioning_extension(operation), + ) + if documentation is None: + continue + extension, description = documentation + documented_operation = self._add_accept_versioning_extension(operation, extension) + if documented_operation is operation and description != operation.description: + documented_operation = copy.copy(operation) + documented_operation.description = description + updates.append((path_item, method, documented_operation)) + return updates -def build_litestar_oauth_flow(oauth_flow: OpenApiOAuthFlow | None) -> openapi.spec.OAuthFlow | None: - if oauth_flow is None: + @staticmethod + def _apply_version_documentation_updates( + updates: typing.Iterable[tuple[openapi.spec.PathItem, str, openapi.spec.Operation]], + ) -> None: + for path_item, method, operation in updates: + setattr(path_item, method, operation) + + @staticmethod + def _read_accept_versioning_extension(operation: openapi.spec.Operation) -> object: + for field in dataclasses.fields(operation): + if field.metadata.get("alias") == "x-accept-versioning": + return getattr(operation, field.name) return None - return openapi.spec.OAuthFlow( - authorization_url=oauth_flow.authorization_url, - token_url=oauth_flow.token_url, - refresh_url=oauth_flow.refresh_url, - scopes=oauth_flow.scopes, - ) - -def add_accept_versioning_extension( - operation: openapi.spec.Operation, - expected_extension: dict[str, str | list[str]], -) -> openapi.spec.Operation: - extension_name: typing.Final = "x-accept-versioning" - for field in dataclasses.fields(operation): - if field.metadata.get("alias") != extension_name: - continue - existing_extension = getattr(operation, field.name) - if existing_extension is None: + @staticmethod + def _has_accept_versioning_extension(operation: openapi.spec.Operation) -> bool: + return LitestarSwaggerInstrument._read_accept_versioning_extension(operation) is not None + + def _add_accept_versioning_extension( + self, + operation: openapi.spec.Operation, + expected_extension: dict[str, str | list[str]], + ) -> openapi.spec.Operation: + extension_name: typing.Final = "x-accept-versioning" + for field in dataclasses.fields(operation): + if field.metadata.get("alias") != extension_name: + continue + existing_extension = getattr(operation, field.name) + self._validate_accept_versioning_extension( + existing_extension, + expected_extension, + has_existing_extension=existing_extension is not None, + ) + if existing_extension is not None: + return operation versioned_operation = copy.copy(operation) setattr(versioned_operation, field.name, expected_extension) return versioned_operation - if existing_extension != expected_extension: - message = f"OpenAPI operation {extension_name} conflicts with configured Accept version documentation." - raise ValueError(message) - return operation - - if type(operation) is not openapi.spec.Operation: - message = ( - f"OpenAPI operation {type(operation).__name__} must declare an {extension_name} alias " - "to use Accept version documentation." + + if type(operation) is not openapi.spec.Operation: + message = ( + f"OpenAPI operation {type(operation).__name__} must declare an {extension_name} alias " + "to use Accept version documentation." + ) + raise TypeError(message) + return AcceptVersionedOperation( + tags=operation.tags, + summary=operation.summary, + description=operation.description, + external_docs=operation.external_docs, + operation_id=operation.operation_id, + parameters=operation.parameters, + request_body=operation.request_body, + responses=operation.responses, + callbacks=operation.callbacks, + deprecated=operation.deprecated, + security=operation.security, + servers=operation.servers, + accept_versioning=expected_extension, + ) + + @staticmethod + def _build_litestar_security_scheme(security_scheme: OpenApiSecurityScheme) -> openapi.spec.SecurityScheme: + if isinstance(security_scheme, OpenApiHttpSecurityScheme): + return openapi.spec.SecurityScheme( + type=security_scheme.type, + scheme=security_scheme.scheme, + bearer_format=security_scheme.bearer_format, + description=security_scheme.description, + ) + if isinstance(security_scheme, OpenApiApiKeySecurityScheme): + return openapi.spec.SecurityScheme( + type=security_scheme.type, + name=security_scheme.name, + security_scheme_in=security_scheme.location, + description=security_scheme.description, + ) + if isinstance(security_scheme, OpenApiOAuth2SecurityScheme): + return openapi.spec.SecurityScheme( + type=security_scheme.type, + flows=LitestarSwaggerInstrument._build_litestar_oauth_flows(security_scheme.flows), + description=security_scheme.description, + ) + if isinstance(security_scheme, OpenApiOpenIdConnectSecurityScheme): + return openapi.spec.SecurityScheme( + type=security_scheme.type, + open_id_connect_url=security_scheme.open_id_connect_url, + description=security_scheme.description, + ) + raise AssertionError("Unsupported OpenAPI security scheme.") + + @staticmethod + def _build_litestar_oauth_flows(oauth_flows: OpenApiOAuthFlows) -> openapi.spec.OAuthFlows: + password_flow: typing.Final = oauth_flows.resource_owner + return openapi.spec.OAuthFlows( + LitestarSwaggerInstrument._build_litestar_oauth_flow(oauth_flows.implicit), + LitestarSwaggerInstrument._build_litestar_oauth_flow(password_flow), + LitestarSwaggerInstrument._build_litestar_oauth_flow(oauth_flows.client_credentials), + LitestarSwaggerInstrument._build_litestar_oauth_flow(oauth_flows.authorization_code), + ) + + @staticmethod + def _build_litestar_oauth_flow(oauth_flow: OpenApiOAuthFlow | None) -> openapi.spec.OAuthFlow | None: + if oauth_flow is None: + return None + return openapi.spec.OAuthFlow( + authorization_url=oauth_flow.authorization_url, + token_url=oauth_flow.token_url, + refresh_url=oauth_flow.refresh_url, + scopes=oauth_flow.scopes, ) - raise TypeError(message) - operation_arguments = {field.name: getattr(operation, field.name) for field in dataclasses.fields(operation)} - return AcceptVersionedOperation(**operation_arguments, accept_versioning=expected_extension) @LitestarBootstrapper.use_instrument() diff --git a/microbootstrap/instruments/openapi_version_docs.py b/microbootstrap/instruments/openapi_version_docs.py index eb4967c..9ff11d5 100644 --- a/microbootstrap/instruments/openapi_version_docs.py +++ b/microbootstrap/instruments/openapi_version_docs.py @@ -97,60 +97,3 @@ def validate_versions(value: tuple[str, ...]) -> tuple[str, ...]: message = "Each supported API version must be a non-empty safe media-type token." raise ValueError(message) return value - - -def get_supported_versions( - configuration: OpenApiVersionDocsConfig, - path: str, - method: str, -) -> tuple[str, ...]: - for override in configuration.operation_versions: - if override.path == path and override.method == method: - return override.supported_versions - return configuration.supported_versions - - -def build_accept_versioning_extension( - configuration: OpenApiVersionDocsConfig, - supported_versions: tuple[str, ...], -) -> dict[str, str | list[str]]: - assert configuration.vendor_media_type is not None # noqa: S101 - enabled configuration guarantees this. - return { - "header": "Accept", - "mediaType": configuration.vendor_media_type, - "parameter": "version", - "supportedVersions": list(supported_versions), - } - - -def build_version_documentation( - configuration: OpenApiVersionDocsConfig, - supported_versions: tuple[str, ...], -) -> str: - media_types: typing.Final = tuple( - f"{configuration.vendor_media_type}; version={version}" for version in supported_versions - ) - if len(media_types) == 1: - return f"Supported API version: `{media_types[0]}`." - return "Supported API versions:\n" + "\n".join(f"- `{media_type}`." for media_type in media_types) - - -def append_version_documentation( - description: str | None, - configuration: OpenApiVersionDocsConfig, - supported_versions: tuple[str, ...], -) -> str: - version_documentation: typing.Final = build_version_documentation(configuration, supported_versions) - if description is None or not description: - return version_documentation - if version_documentation in description: - return description - return f"{description}\n\n{version_documentation}" - - -def is_operation_suppressed( - configuration: OpenApiVersionDocsConfig, - path: str, - method: str, -) -> bool: - return OpenApiOperationSelector(path=path, method=method) in configuration.suppressed_operations diff --git a/microbootstrap/instruments/swagger_instrument.py b/microbootstrap/instruments/swagger_instrument.py index 47867b9..dbbfb72 100644 --- a/microbootstrap/instruments/swagger_instrument.py +++ b/microbootstrap/instruments/swagger_instrument.py @@ -6,9 +6,13 @@ from microbootstrap.helpers import is_valid_path from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument -from microbootstrap.instruments.openapi_security_schemes import OpenApiSecurityScheme # noqa: TC001 +from microbootstrap.instruments.openapi_security_schemes import ( + OpenApiSecurityScheme, + serialize_security_schemes, +) from microbootstrap.instruments.openapi_version_docs import ( - OpenApiVersionDocsConfig, # noqa: TC001 - Pydantic resolves it at runtime. + SUPPORTED_HTTP_METHODS, + OpenApiVersionDocsConfig, ) @@ -47,6 +51,114 @@ class SwaggerInstrument(Instrument[SwaggerConfig]): def is_ready(self) -> bool: return bool(self.instrument_config.swagger_path) and is_valid_path(self.instrument_config.swagger_path) + def _has_version_documentation(self) -> bool: + configuration: typing.Final = self.instrument_config.openapi_version_docs + return configuration is not None and configuration.enabled + + def _prepare_version_documentation( + self, + path: str, + method: str, + description: object, + existing_extension: object, + *, + has_existing_extension: bool, + ) -> tuple[dict[str, str | list[str]], str] | None: + configuration: typing.Final = self.instrument_config.openapi_version_docs + if ( + configuration is None + or not configuration.enabled + or method not in SUPPORTED_HTTP_METHODS + or self._is_operation_suppressed(configuration, path, method) + ): + return None + if description is not None and not isinstance(description, str): + message = f"OpenAPI operation {method.upper()} {path} has a non-string description." + raise ValueError(message) + + supported_versions: typing.Final = self._select_supported_versions(configuration, path, method) + expected_extension: typing.Final = self._build_accept_versioning_extension(configuration, supported_versions) + self._validate_accept_versioning_extension( + existing_extension, + expected_extension, + has_existing_extension=has_existing_extension, + ) + return expected_extension, self._append_version_documentation(description, configuration, supported_versions) + + @staticmethod + def _select_supported_versions( + configuration: OpenApiVersionDocsConfig, + path: str, + method: str, + ) -> tuple[str, ...]: + for override in configuration.operation_versions: + if override.path == path and override.method == method: + return override.supported_versions + return configuration.supported_versions + + @staticmethod + def _is_operation_suppressed( + configuration: OpenApiVersionDocsConfig, + path: str, + method: str, + ) -> bool: + return any( + selector.path == path and selector.method == method for selector in configuration.suppressed_operations + ) + + @staticmethod + def _build_accept_versioning_extension( + configuration: OpenApiVersionDocsConfig, + supported_versions: tuple[str, ...], + ) -> dict[str, str | list[str]]: + assert configuration.vendor_media_type is not None # noqa: S101 - enabled configuration guarantees this. + return { + "header": "Accept", + "mediaType": configuration.vendor_media_type, + "parameter": "version", + "supportedVersions": list(supported_versions), + } + + @staticmethod + def _append_version_documentation( + description: str | None, + configuration: OpenApiVersionDocsConfig, + supported_versions: tuple[str, ...], + ) -> str: + media_types: typing.Final = tuple( + f"{configuration.vendor_media_type}; version={version}" for version in supported_versions + ) + version_documentation: typing.Final = ( + f"Supported API version: `{media_types[0]}`." + if len(media_types) == 1 + else "Supported API versions:\n" + "\n".join(f"- `{media_type}`." for media_type in media_types) + ) + if not description: + return version_documentation + if version_documentation in description: + return description + return f"{description}\n\n{version_documentation}" + + @staticmethod + def _validate_accept_versioning_extension( + existing_extension: object, + expected_extension: dict[str, str | list[str]], + *, + has_existing_extension: bool, + ) -> None: + if has_existing_extension and existing_extension != expected_extension: + message = "OpenAPI operation x-accept-versioning conflicts with configured Accept version documentation." + raise ValueError(message) + + def _expected_security_schemes(self) -> dict[str, dict[str, typing.Any]]: + return serialize_security_schemes(self.instrument_config.security_schemes) + + def _validate_security_scheme_conflicts(self, existing_schemes: typing.Mapping[str, object]) -> None: + for scheme_name, expected_scheme in self._expected_security_schemes().items(): + if scheme_name in existing_schemes and existing_schemes[scheme_name] != expected_scheme: + message = f"OpenAPI security scheme '{scheme_name}' conflicts with the configured security scheme." + raise ValueError(message) + @classmethod def get_config_type(cls) -> type[SwaggerConfig]: return SwaggerConfig diff --git a/tests/bootstrappers/test_openapi_version_docs.py b/tests/bootstrappers/test_openapi_version_docs.py index 08c7bfb..070077b 100644 --- a/tests/bootstrappers/test_openapi_version_docs.py +++ b/tests/bootstrappers/test_openapi_version_docs.py @@ -31,7 +31,6 @@ from microbootstrap.bootstrappers.litestar import ( LitestarBootstrapper, LitestarSwaggerInstrument, - add_accept_versioning_extension, ) from microbootstrap.config.litestar import LitestarConfig from microbootstrap.instruments.openapi_security_schemes import serialize_security_schemes @@ -484,6 +483,43 @@ def test_fastapi_combined_openapi_augmentation_is_atomic_and_retryable(failure: assert served_schema.json() == first_schema +def test_fastapi_default_openapi_cache_retries_after_a_corrected_conflict() -> None: + application: typing.Final = fastapi.FastAPI() + + @application.get( + TARGET_PATH, + description=GET_DESCRIPTION, + openapi_extra={"x-accept-versioning": {"header": "X-Service-Version"}}, + ) + async def list_widgets() -> dict[str, str]: + return {"status": "ok"} + + FastApiSwaggerInstrument( + SwaggerConfig( + security_schemes={"serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer")}, + openapi_version_docs=build_version_docs_config(), + ) + ).bootstrap_after(application) + + with pytest.raises(ValueError, match="x-accept-versioning conflicts"): + application.openapi() + + cached_schema: typing.Final = application.openapi_schema + assert cached_schema is not None + assert "serviceAuth" not in cached_schema.get("components", {}).get("securitySchemes", {}) + assert cached_schema["paths"][TARGET_PATH]["get"]["x-accept-versioning"] == {"header": "X-Service-Version"} + + cached_schema["paths"][TARGET_PATH]["get"].pop("x-accept-versioning") + corrected_schema: typing.Final = application.openapi() + + assert corrected_schema is cached_schema + assert corrected_schema["components"]["securitySchemes"]["serviceAuth"] == { + "type": "http", + "scheme": "bearer", + } + assert corrected_schema["paths"][TARGET_PATH]["get"]["x-accept-versioning"]["header"] == "Accept" + + @pytest.mark.parametrize( ("failure", "error"), [ @@ -502,18 +538,20 @@ def test_litestar_combined_openapi_augmentation_is_atomic_and_retryable(failure: assert path_item.get is not None assert path_item.post is not None original_get: typing.Final = path_item.get - if failure == "conflicting_extension": - path_item.post = add_accept_versioning_extension(path_item.post, {"header": "X-Service-Version"}) - else: - path_item.post.description = 1 # type: ignore[assignment] # Deliberately invalid service-owned schema. - baseline_schema: typing.Final = copy.deepcopy(application.openapi_schema.to_schema()) instrument: typing.Final = LitestarSwaggerInstrument( SwaggerConfig( security_schemes={"serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer")}, openapi_version_docs=build_version_docs_config(), ) ) - + if failure == "conflicting_extension": + path_item.post = instrument._add_accept_versioning_extension( # noqa: SLF001 - setup through the instrument. + path_item.post, + {"header": "X-Service-Version"}, + ) + else: + path_item.post.description = 1 # type: ignore[assignment] # Deliberately invalid service-owned schema. + baseline_schema: typing.Final = copy.deepcopy(application.openapi_schema.to_schema()) with pytest.raises(ValueError, match=error): instrument.bootstrap_after(application) @@ -782,9 +820,10 @@ def to_schema(self) -> dict[str, typing.Any]: service_owned_operation.rendering_state = "published" path_item.get = service_owned_operation - LitestarSwaggerInstrument(SwaggerConfig(openapi_version_docs=build_version_docs_config())).bootstrap_after( - application + instrument: typing.Final = LitestarSwaggerInstrument( + SwaggerConfig(openapi_version_docs=build_version_docs_config()) ) + instrument.bootstrap_after(application) documented_operation: typing.Final = path_item.get assert type(documented_operation) is ServiceVersionedOperation @@ -792,11 +831,14 @@ def to_schema(self) -> dict[str, typing.Any]: assert documented_operation.rendering_state == "published" assert documented_operation.to_schema()["x-accept-versioning"] == expected_extension - assert add_accept_versioning_extension(documented_operation, expected_extension) is documented_operation + assert ( + instrument._add_accept_versioning_extension(documented_operation, expected_extension) # noqa: SLF001 + is documented_operation + ) conflicting_operation: typing.Final = copy.copy(documented_operation) conflicting_operation.accept_versioning = {**expected_extension, "header": "X-Service-Version"} with pytest.raises(ValueError, match="x-accept-versioning conflicts"): - add_accept_versioning_extension(conflicting_operation, expected_extension) + instrument._add_accept_versioning_extension(conflicting_operation, expected_extension) # noqa: SLF001 def test_litestar_version_docs_reject_unsupported_custom_operation_atomically() -> None: From 95661ea92677c63fcee6e55ffdc401cd36a99880 Mon Sep 17 00:00:00 2001 From: Mateo Li Date: Thu, 1 Oct 2026 10:03:43 +0300 Subject: [PATCH 04/10] Simplify OpenAPI version documentation configuration --- README.md | 16 +- microbootstrap/__init__.py | 2 - microbootstrap/bootstrappers/fastapi.py | 24 +- microbootstrap/bootstrappers/litestar.py | 154 +- .../instruments/openapi_version_docs.py | 43 +- .../instruments/swagger_instrument.py | 86 +- .../test_openapi_version_docs.py | 1303 ++++++++--------- tests/instruments/test_swagger.py | 73 +- 8 files changed, 782 insertions(+), 919 deletions(-) diff --git a/README.md b/README.md index cc515a3..c2a6cf1 100644 --- a/README.md +++ b/README.md @@ -604,7 +604,6 @@ Keep requirements and authentication in your application routes and dependencies from microbootstrap import ( LitestarSettings, OpenApiHttpSecurityScheme, - OpenApiOperationSelector, OpenApiOperationVersionOverride, OpenApiSecurityScheme, OpenApiVersionDocsConfig, @@ -616,12 +615,11 @@ class Settings(LitestarSettings): "serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer", bearer_format="JWT"), } openapi_version_docs: OpenApiVersionDocsConfig | None = OpenApiVersionDocsConfig( - enabled=True, vendor_media_type="application/vnd.example+json", supported_versions=("1.0",), - suppressed_operations=(OpenApiOperationSelector(path="/internal/widgets", method="get"),), operation_versions=( OpenApiOperationVersionOverride(path="/widgets", method="post", supported_versions=("2.0",)), + OpenApiOperationVersionOverride(path="/internal/widgets", method="get", supported_versions=()), ), ) ``` @@ -630,10 +628,14 @@ HTTP, API key, OAuth 2.0, and OpenID Connect definitions are supported. Python f accepted; output uses canonical names such as `bearerFormat`, `in`, `tokenUrl`, and `openIdConnectUrl`. A same-named definition must be identical to the service-owned definition or schema generation raises `ValueError`. -Version documentation is a fixed Accept-media-type convention. Each non-suppressed operation gets an -`x-accept-versioning` extension and matching description text; `operation_versions` replaces the project version list -for one exact path and lower-case HTTP method. It does not negotiate requests, add an `Accept` parameter, change response -media types, or provide a Swagger UI version selector. +Set `openapi_version_docs` to `None` to disable version documentation. A configured non-empty global +`supported_versions` list adds an `x-accept-versioning` extension and matching description text to each operation. +`operation_versions` replaces that list for one exact path and lower-case HTTP method; an explicit empty tuple skips +microbootstrap's additions for that operation without asserting that no service-owned version metadata exists. This does +not negotiate requests, add an `Accept` parameter, change response media types, or provide a Swagger UI version selector. + +Breaking migration: replace `enabled=False` with `openapi_version_docs=None`, and replace each old suppressed operation +with an `OpenApiOperationVersionOverride` whose `supported_versions=()`. There is no compatibility shim. For Litestar, custom `Operation` subclasses must explicitly declare an `x-accept-versioning`-aliased dataclass field; unsupported subclasses are rejected rather than losing custom state. For FastAPI, configure documentation before the diff --git a/microbootstrap/__init__.py b/microbootstrap/__init__.py index 54cf217..c0884b5 100644 --- a/microbootstrap/__init__.py +++ b/microbootstrap/__init__.py @@ -11,7 +11,6 @@ OpenApiSecurityScheme, ) from microbootstrap.instruments.openapi_version_docs import ( - OpenApiOperationSelector, OpenApiOperationVersionOverride, OpenApiVersionDocsConfig, ) @@ -62,7 +61,6 @@ "OpenApiOAuthFlow", "OpenApiOAuthFlows", "OpenApiOpenIdConnectSecurityScheme", - "OpenApiOperationSelector", "OpenApiOperationVersionOverride", "OpenApiSecurityScheme", "OpenApiVersionDocsConfig", diff --git a/microbootstrap/bootstrappers/fastapi.py b/microbootstrap/bootstrappers/fastapi.py index 8f00f54..862702c 100644 --- a/microbootstrap/bootstrappers/fastapi.py +++ b/microbootstrap/bootstrappers/fastapi.py @@ -12,6 +12,7 @@ from microbootstrap.instruments.cors_instrument import CorsInstrument from microbootstrap.instruments.health_checks_instrument import HealthChecksInstrument, HealthCheckTypedDict from microbootstrap.instruments.logging_instrument import LoggingInstrument +from microbootstrap.instruments.openapi_security_schemes import serialize_security_schemes from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument from microbootstrap.instruments.prometheus_instrument import FastApiPrometheusConfig, PrometheusInstrument from microbootstrap.instruments.pyroscope_instrument import PyroscopeInstrument @@ -67,17 +68,21 @@ def bootstrap_before(self) -> dict[str, typing.Any]: def bootstrap_after(self, application: ApplicationT) -> ApplicationT: if self.instrument_config.swagger_offline_docs: enable_offline_docs(application, static_files_handler=self.instrument_config.service_static_path) - if not self._has_version_documentation() and not self.instrument_config.security_schemes: + version_docs = self.instrument_config.openapi_version_docs + if version_docs is None and not self.instrument_config.security_schemes: return application original_openapi: typing.Final = application.openapi def documented_openapi() -> dict[str, typing.Any]: openapi_schema: typing.Final = original_openapi() - expected_schemes: typing.Final = self._prepare_security_scheme_updates(openapi_schema) + security_updates: dict[str, dict[str, typing.Any]] | None = None + if self.instrument_config.security_schemes: + expected_schemes: typing.Final = serialize_security_schemes(self.instrument_config.security_schemes) + security_updates = self._prepare_security_scheme_updates(openapi_schema, expected_schemes) version_documentation: typing.Final = self._prepare_version_documentation_updates(openapi_schema) - if expected_schemes is not None: - self._apply_security_scheme_updates(openapi_schema, expected_schemes) + if security_updates is not None: + self._apply_security_scheme_updates(openapi_schema, security_updates) self._apply_version_documentation_updates(version_documentation) return openapi_schema @@ -87,10 +92,8 @@ def documented_openapi() -> dict[str, typing.Any]: def _prepare_security_scheme_updates( self, openapi_schema: dict[str, typing.Any], - ) -> dict[str, dict[str, typing.Any]] | None: - if not self.instrument_config.security_schemes: - return None - expected_schemes: typing.Final = self._expected_security_schemes() + expected_schemes: dict[str, dict[str, typing.Any]], + ) -> dict[str, dict[str, typing.Any]]: components = openapi_schema.get("components") if components is None: return expected_schemes @@ -103,7 +106,7 @@ def _prepare_security_scheme_updates( if not isinstance(security_schemes, dict): message = "OpenAPI components.securitySchemes must be a dictionary to configure security schemes." raise TypeError(message) - self._validate_security_scheme_conflicts(security_schemes) + self._validate_security_scheme_conflicts(security_schemes, expected_schemes) return expected_schemes @staticmethod @@ -130,7 +133,8 @@ def _prepare_version_documentation_updates( openapi_schema: dict[str, typing.Any], ) -> list[tuple[dict[str, typing.Any], dict[str, str | list[str]], str]]: updates: list[tuple[dict[str, typing.Any], dict[str, str | list[str]], str]] = [] - if not self._has_version_documentation(): + configuration = self.instrument_config.openapi_version_docs + if configuration is None: return updates paths = openapi_schema.get("paths") if not isinstance(paths, dict): diff --git a/microbootstrap/bootstrappers/litestar.py b/microbootstrap/bootstrappers/litestar.py index 1415aa1..a2397ca 100644 --- a/microbootstrap/bootstrappers/litestar.py +++ b/microbootstrap/bootstrappers/litestar.py @@ -35,6 +35,7 @@ OpenApiOAuthFlows, OpenApiOpenIdConnectSecurityScheme, OpenApiSecurityScheme, + serialize_security_schemes, ) from microbootstrap.instruments.openapi_version_docs import SUPPORTED_HTTP_METHODS from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument @@ -126,30 +127,31 @@ def bootstrap_before(self) -> dict[str, typing.Any]: return bootstrap_result def bootstrap_after(self, application: ApplicationT) -> ApplicationT: - if ( - not self._has_version_documentation() and not self.instrument_config.security_schemes - ) or application.openapi_schema is None: + version_docs = self.instrument_config.openapi_version_docs + if (version_docs is None and not self.instrument_config.security_schemes) or application.openapi_schema is None: return application - expected_schemes: typing.Final = self._prepare_security_scheme_updates(application.openapi_schema) + security_updates: dict[str, openapi.spec.SecurityScheme] | None = None + if self.instrument_config.security_schemes: + expected_schemes: typing.Final = serialize_security_schemes(self.instrument_config.security_schemes) + security_updates = self._prepare_security_scheme_updates(application.openapi_schema, expected_schemes) version_documentation: typing.Final = self._prepare_version_documentation_updates(application.openapi_schema) - if expected_schemes is not None: - self._apply_security_scheme_updates(application.openapi_schema, expected_schemes) + if security_updates is not None: + self._apply_security_scheme_updates(application.openapi_schema, security_updates) self._apply_version_documentation_updates(version_documentation) return application def _prepare_security_scheme_updates( self, openapi_schema: openapi.spec.OpenAPI, - ) -> dict[str, openapi.spec.SecurityScheme] | None: - if not self.instrument_config.security_schemes: - return None + expected_schemes: dict[str, dict[str, typing.Any]], + ) -> dict[str, openapi.spec.SecurityScheme]: security_schemes = openapi_schema.components.security_schemes if security_schemes is not None: canonical_schemes: typing.Final = { name: scheme.to_schema() if isinstance(scheme, openapi.spec.SecurityScheme) else scheme for name, scheme in security_schemes.items() } - self._validate_security_scheme_conflicts(canonical_schemes) + self._validate_security_scheme_conflicts(canonical_schemes, expected_schemes) return { scheme_name: self._build_litestar_security_scheme(security_scheme) for scheme_name, security_scheme in self.instrument_config.security_schemes.items() @@ -176,7 +178,8 @@ def _prepare_version_documentation_updates( openapi_schema: openapi.spec.OpenAPI, ) -> list[tuple[openapi.spec.PathItem, str, openapi.spec.Operation]]: updates: list[tuple[openapi.spec.PathItem, str, openapi.spec.Operation]] = [] - if not self._has_version_documentation() or openapi_schema.paths is None: + configuration = self.instrument_config.openapi_version_docs + if configuration is None or openapi_schema.paths is None: return updates for path, path_item in openapi_schema.paths.items(): if not isinstance(path_item, openapi.spec.PathItem): @@ -185,20 +188,34 @@ def _prepare_version_documentation_updates( operation = getattr(path_item, method) if operation is None: continue + accept_versioning_field = next( + ( + field + for field in dataclasses.fields(operation) + if field.metadata.get("alias") == "x-accept-versioning" + ), + None, + ) + existing_extension = ( + getattr(operation, accept_versioning_field.name) if accept_versioning_field is not None else None + ) documentation = self._prepare_version_documentation( path, method, operation.description, - self._read_accept_versioning_extension(operation), - has_existing_extension=self._has_accept_versioning_extension(operation), + existing_extension, + has_existing_extension=existing_extension is not None, ) if documentation is None: continue extension, description = documentation - documented_operation = self._add_accept_versioning_extension(operation, extension) - if documented_operation is operation and description != operation.description: - documented_operation = copy.copy(operation) - documented_operation.description = description + documented_operation = self._prepare_documented_operation( + operation, + accept_versioning_field, + existing_extension, + extension, + description, + ) updates.append((path_item, method, documented_operation)) return updates @@ -210,61 +227,40 @@ def _apply_version_documentation_updates( setattr(path_item, method, operation) @staticmethod - def _read_accept_versioning_extension(operation: openapi.spec.Operation) -> object: - for field in dataclasses.fields(operation): - if field.metadata.get("alias") == "x-accept-versioning": - return getattr(operation, field.name) - return None - - @staticmethod - def _has_accept_versioning_extension(operation: openapi.spec.Operation) -> bool: - return LitestarSwaggerInstrument._read_accept_versioning_extension(operation) is not None - - def _add_accept_versioning_extension( - self, + def _prepare_documented_operation( operation: openapi.spec.Operation, + accept_versioning_field: dataclasses.Field[typing.Any] | None, + existing_extension: object, expected_extension: dict[str, str | list[str]], + description: str, ) -> openapi.spec.Operation: - extension_name: typing.Final = "x-accept-versioning" - for field in dataclasses.fields(operation): - if field.metadata.get("alias") != extension_name: - continue - existing_extension = getattr(operation, field.name) - self._validate_accept_versioning_extension( - existing_extension, - expected_extension, - has_existing_extension=existing_extension is not None, - ) + if accept_versioning_field is not None: if existing_extension is not None: - return operation - versioned_operation = copy.copy(operation) - setattr(versioned_operation, field.name, expected_extension) - return versioned_operation - - if type(operation) is not openapi.spec.Operation: - message = ( - f"OpenAPI operation {type(operation).__name__} must declare an {extension_name} alias " - "to use Accept version documentation." - ) - raise TypeError(message) - return AcceptVersionedOperation( - tags=operation.tags, - summary=operation.summary, - description=operation.description, - external_docs=operation.external_docs, - operation_id=operation.operation_id, - parameters=operation.parameters, - request_body=operation.request_body, - responses=operation.responses, - callbacks=operation.callbacks, - deprecated=operation.deprecated, - security=operation.security, - servers=operation.servers, - accept_versioning=expected_extension, - ) + documented_operation = operation + else: + documented_operation = copy.copy(operation) + setattr(documented_operation, accept_versioning_field.name, expected_extension) + else: + if type(operation) is not openapi.spec.Operation: + message = ( + f"OpenAPI operation {type(operation).__name__} must declare an x-accept-versioning alias " + "to use Accept version documentation." + ) + raise TypeError(message) + init_fields = { + field.name: getattr(operation, field.name) + for field in dataclasses.fields(openapi.spec.Operation) + if field.init + } + documented_operation = AcceptVersionedOperation(**init_fields, accept_versioning=expected_extension) + if description != operation.description: + if documented_operation is operation: + documented_operation = copy.copy(documented_operation) + documented_operation.description = description + return documented_operation - @staticmethod - def _build_litestar_security_scheme(security_scheme: OpenApiSecurityScheme) -> openapi.spec.SecurityScheme: + @classmethod + def _build_litestar_security_scheme(cls, security_scheme: OpenApiSecurityScheme) -> openapi.spec.SecurityScheme: if isinstance(security_scheme, OpenApiHttpSecurityScheme): return openapi.spec.SecurityScheme( type=security_scheme.type, @@ -282,7 +278,7 @@ def _build_litestar_security_scheme(security_scheme: OpenApiSecurityScheme) -> o if isinstance(security_scheme, OpenApiOAuth2SecurityScheme): return openapi.spec.SecurityScheme( type=security_scheme.type, - flows=LitestarSwaggerInstrument._build_litestar_oauth_flows(security_scheme.flows), + flows=cls._build_litestar_oauth_flows(security_scheme.flows), description=security_scheme.description, ) if isinstance(security_scheme, OpenApiOpenIdConnectSecurityScheme): @@ -293,18 +289,18 @@ def _build_litestar_security_scheme(security_scheme: OpenApiSecurityScheme) -> o ) raise AssertionError("Unsupported OpenAPI security scheme.") - @staticmethod - def _build_litestar_oauth_flows(oauth_flows: OpenApiOAuthFlows) -> openapi.spec.OAuthFlows: - password_flow: typing.Final = oauth_flows.resource_owner - return openapi.spec.OAuthFlows( - LitestarSwaggerInstrument._build_litestar_oauth_flow(oauth_flows.implicit), - LitestarSwaggerInstrument._build_litestar_oauth_flow(password_flow), - LitestarSwaggerInstrument._build_litestar_oauth_flow(oauth_flows.client_credentials), - LitestarSwaggerInstrument._build_litestar_oauth_flow(oauth_flows.authorization_code), - ) + @classmethod + def _build_litestar_oauth_flows(cls, oauth_flows: OpenApiOAuthFlows) -> openapi.spec.OAuthFlows: + flows = { + "implicit": cls._build_litestar_oauth_flow(oauth_flows.implicit), + "password": cls._build_litestar_oauth_flow(object.__getattribute__(oauth_flows, "resource_owner")), + "client_credentials": cls._build_litestar_oauth_flow(oauth_flows.client_credentials), + "authorization_code": cls._build_litestar_oauth_flow(oauth_flows.authorization_code), + } + return openapi.spec.OAuthFlows(**flows) - @staticmethod - def _build_litestar_oauth_flow(oauth_flow: OpenApiOAuthFlow | None) -> openapi.spec.OAuthFlow | None: + @classmethod + def _build_litestar_oauth_flow(cls, oauth_flow: OpenApiOAuthFlow | None) -> openapi.spec.OAuthFlow | None: if oauth_flow is None: return None return openapi.spec.OAuthFlow( diff --git a/microbootstrap/instruments/openapi_version_docs.py b/microbootstrap/instruments/openapi_version_docs.py index 9ff11d5..fb03e5d 100644 --- a/microbootstrap/instruments/openapi_version_docs.py +++ b/microbootstrap/instruments/openapi_version_docs.py @@ -10,9 +10,12 @@ VENDOR_MEDIA_TYPE: typing.Final = re.compile(r"application/vnd\.([!#$%&'*+\-.^_|~0-9A-Za-z]+)\+json\Z") -class OpenApiOperationSelector(pydantic.BaseModel): +class OpenApiOperationVersionOverride(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + path: str method: str + supported_versions: tuple[str, ...] @pydantic.field_validator("path") @classmethod @@ -30,32 +33,22 @@ def validate_method(cls, value: str) -> str: raise ValueError(message) return value - -class OpenApiOperationVersionOverride(OpenApiOperationSelector): - supported_versions: tuple[str, ...] - @pydantic.field_validator("supported_versions") @classmethod def validate_supported_versions(cls, value: tuple[str, ...]) -> tuple[str, ...]: - validated_versions = validate_versions(value) - if not validated_versions: - message = "Operation version overrides must contain at least one supported API version." - raise ValueError(message) - return validated_versions + return validate_versions(value) class OpenApiVersionDocsConfig(pydantic.BaseModel): - enabled: bool = False - vendor_media_type: str | None = None - supported_versions: tuple[str, ...] = () - suppressed_operations: tuple[OpenApiOperationSelector, ...] = () + model_config = pydantic.ConfigDict(extra="forbid") + + vendor_media_type: str + supported_versions: tuple[str, ...] operation_versions: tuple[OpenApiOperationVersionOverride, ...] = () @pydantic.field_validator("vendor_media_type") @classmethod - def validate_vendor_media_type(cls, value: str | None) -> str | None: - if value is None: - return value + def validate_vendor_media_type(cls, value: str) -> str: if VENDOR_MEDIA_TYPE.fullmatch(value) is None: message = "Vendor media type must use the application/vnd.+json form." raise ValueError(message) @@ -64,7 +57,11 @@ def validate_vendor_media_type(cls, value: str | None) -> str | None: @pydantic.field_validator("supported_versions") @classmethod def validate_supported_versions(cls, value: tuple[str, ...]) -> tuple[str, ...]: - return validate_versions(value) + validated_versions = validate_versions(value) + if not validated_versions: + message = "OpenAPI version documentation requires at least one supported API version." + raise ValueError(message) + return validated_versions @pydantic.field_validator("operation_versions") @classmethod @@ -78,16 +75,6 @@ def validate_operation_versions( raise ValueError(message) return value - @pydantic.model_validator(mode="after") - def validate_enabled_configuration(self) -> OpenApiVersionDocsConfig: - if self.enabled and self.vendor_media_type is None: - message = "Enabled OpenAPI version documentation requires an explicit vendor media type." - raise ValueError(message) - if self.enabled and not self.supported_versions: - message = "Enabled OpenAPI version documentation requires at least one supported API version." - raise ValueError(message) - return self - def validate_versions(value: tuple[str, ...]) -> tuple[str, ...]: if len(value) != len(set(value)): diff --git a/microbootstrap/instruments/swagger_instrument.py b/microbootstrap/instruments/swagger_instrument.py index dbbfb72..3338d64 100644 --- a/microbootstrap/instruments/swagger_instrument.py +++ b/microbootstrap/instruments/swagger_instrument.py @@ -6,10 +6,7 @@ from microbootstrap.helpers import is_valid_path from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument -from microbootstrap.instruments.openapi_security_schemes import ( - OpenApiSecurityScheme, - serialize_security_schemes, -) +from microbootstrap.instruments.openapi_security_schemes import OpenApiSecurityScheme # noqa: TC001 from microbootstrap.instruments.openapi_version_docs import ( SUPPORTED_HTTP_METHODS, OpenApiVersionDocsConfig, @@ -51,10 +48,6 @@ class SwaggerInstrument(Instrument[SwaggerConfig]): def is_ready(self) -> bool: return bool(self.instrument_config.swagger_path) and is_valid_path(self.instrument_config.swagger_path) - def _has_version_documentation(self) -> bool: - configuration: typing.Final = self.instrument_config.openapi_version_docs - return configuration is not None and configuration.enabled - def _prepare_version_documentation( self, path: str, @@ -64,63 +57,39 @@ def _prepare_version_documentation( *, has_existing_extension: bool, ) -> tuple[dict[str, str | list[str]], str] | None: - configuration: typing.Final = self.instrument_config.openapi_version_docs - if ( - configuration is None - or not configuration.enabled - or method not in SUPPORTED_HTTP_METHODS - or self._is_operation_suppressed(configuration, path, method) - ): + configuration = self.instrument_config.openapi_version_docs + if configuration is None or method not in SUPPORTED_HTTP_METHODS: + return None + + supported_versions = next( + ( + override.supported_versions + for override in configuration.operation_versions + if override.path == path and override.method == method + ), + configuration.supported_versions, + ) + if not supported_versions: return None if description is not None and not isinstance(description, str): message = f"OpenAPI operation {method.upper()} {path} has a non-string description." raise ValueError(message) - supported_versions: typing.Final = self._select_supported_versions(configuration, path, method) - expected_extension: typing.Final = self._build_accept_versioning_extension(configuration, supported_versions) - self._validate_accept_versioning_extension( - existing_extension, - expected_extension, - has_existing_extension=has_existing_extension, - ) - return expected_extension, self._append_version_documentation(description, configuration, supported_versions) - - @staticmethod - def _select_supported_versions( - configuration: OpenApiVersionDocsConfig, - path: str, - method: str, - ) -> tuple[str, ...]: - for override in configuration.operation_versions: - if override.path == path and override.method == method: - return override.supported_versions - return configuration.supported_versions - - @staticmethod - def _is_operation_suppressed( - configuration: OpenApiVersionDocsConfig, - path: str, - method: str, - ) -> bool: - return any( - selector.path == path and selector.method == method for selector in configuration.suppressed_operations - ) - - @staticmethod - def _build_accept_versioning_extension( - configuration: OpenApiVersionDocsConfig, - supported_versions: tuple[str, ...], - ) -> dict[str, str | list[str]]: - assert configuration.vendor_media_type is not None # noqa: S101 - enabled configuration guarantees this. - return { + expected_extension: typing.Final[dict[str, str | list[str]]] = { "header": "Accept", "mediaType": configuration.vendor_media_type, "parameter": "version", "supportedVersions": list(supported_versions), } + self._validate_accept_versioning_extension( + existing_extension, + expected_extension, + has_existing_extension=has_existing_extension, + ) + return expected_extension, self._format_version_documentation(description, configuration, supported_versions) @staticmethod - def _append_version_documentation( + def _format_version_documentation( description: str | None, configuration: OpenApiVersionDocsConfig, supported_versions: tuple[str, ...], @@ -150,11 +119,12 @@ def _validate_accept_versioning_extension( message = "OpenAPI operation x-accept-versioning conflicts with configured Accept version documentation." raise ValueError(message) - def _expected_security_schemes(self) -> dict[str, dict[str, typing.Any]]: - return serialize_security_schemes(self.instrument_config.security_schemes) - - def _validate_security_scheme_conflicts(self, existing_schemes: typing.Mapping[str, object]) -> None: - for scheme_name, expected_scheme in self._expected_security_schemes().items(): + @staticmethod + def _validate_security_scheme_conflicts( + existing_schemes: typing.Mapping[str, object], + expected_schemes: typing.Mapping[str, object], + ) -> None: + for scheme_name, expected_scheme in expected_schemes.items(): if scheme_name in existing_schemes and existing_schemes[scheme_name] != expected_scheme: message = f"OpenAPI security scheme '{scheme_name}' conflicts with the configured security scheme." raise ValueError(message) diff --git a/tests/bootstrappers/test_openapi_version_docs.py b/tests/bootstrappers/test_openapi_version_docs.py index 070077b..0554e84 100644 --- a/tests/bootstrappers/test_openapi_version_docs.py +++ b/tests/bootstrappers/test_openapi_version_docs.py @@ -21,45 +21,48 @@ OpenApiOAuthFlow, OpenApiOAuthFlows, OpenApiOpenIdConnectSecurityScheme, - OpenApiOperationSelector, OpenApiOperationVersionOverride, OpenApiSecurityScheme, OpenApiVersionDocsConfig, SwaggerConfig, ) from microbootstrap.bootstrappers.fastapi import FastApiBootstrapper, FastApiSwaggerInstrument -from microbootstrap.bootstrappers.litestar import ( - LitestarBootstrapper, - LitestarSwaggerInstrument, -) +from microbootstrap.bootstrappers.litestar import LitestarBootstrapper, LitestarSwaggerInstrument from microbootstrap.config.litestar import LitestarConfig -from microbootstrap.instruments.openapi_security_schemes import serialize_security_schemes from microbootstrap.settings import FastApiSettings, LitestarSettings TARGET_PATH: typing.Final = "/widgets" -UNCHANGED_PATH: typing.Final = "/service-health" -MISSING_DESCRIPTION_PATH: typing.Final = "/without-description" -VERSION_DOCUMENTATION: typing.Final = ( +HEALTH_PATH: typing.Final = "/service-health" +NO_DESCRIPTION_PATH: typing.Final = "/without-description" +EXTENSION: typing.Final = { + "header": "Accept", + "mediaType": "application/vnd.real-api+json", + "parameter": "version", + "supportedVersions": ["2026-01", "release-candidate"], +} +VERSION_TEXT: typing.Final = ( "Supported API versions:\n" "- `application/vnd.real-api+json; version=2026-01`.\n" "- `application/vnd.real-api+json; version=release-candidate`." ) -GET_DESCRIPTION: typing.Final = "List widgets" -POST_DESCRIPTION: typing.Final = "Create widget" -LATE_OPERATION_DESCRIPTION: typing.Final = "Changed after the schema was first served" EXPECTED_GENERATOR_CALLS: typing.Final = 2 +VERSIONED_OPERATIONS: typing.Final = ( + (TARGET_PATH, "get"), + (TARGET_PATH, "post"), + (HEALTH_PATH, "get"), + (NO_DESCRIPTION_PATH, "get"), +) -class ServiceOwnedResponse(BaseModel): +class ServiceResponse(BaseModel): status: str @dataclasses.dataclass -class BuiltSwaggerApplication: +class BuiltApplication: framework: str application: fastapi.FastAPI | litestar.Litestar - schema_path: str renderer: SwaggerRenderPlugin | None = None def schema(self) -> dict[str, typing.Any]: @@ -68,115 +71,125 @@ def schema(self) -> dict[str, typing.Any]: return self.application.openapi_schema.to_schema() return self.application.openapi() + def served_schema(self) -> dict[str, typing.Any]: + if isinstance(self.application, litestar.Litestar): + with LitestarTestClient(app=self.application) as client: + response = client.get("/schema/openapi.json") + else: + with FastAPITestClient(app=self.application) as client: + response = client.get("/openapi.json") + assert response.status_code == status_codes.HTTP_200_OK + return typing.cast("dict[str, typing.Any]", response.json()) + + def create_widget(self, headers: dict[str, str] | None = None) -> None: + if isinstance(self.application, litestar.Litestar): + with LitestarTestClient(app=self.application) as client: + response = client.post(TARGET_PATH, headers=headers) + expected_status = status_codes.HTTP_201_CREATED + else: + with FastAPITestClient(app=self.application) as client: + response = client.post(TARGET_PATH, headers=headers) + expected_status = status_codes.HTTP_200_OK + assert response.status_code == expected_status + assert response.headers["content-type"] == "application/json" + assert response.json() == {"status": "created"} + @pytest.fixture(params=("fastapi", "litestar")) -def swagger_framework(request: pytest.FixtureRequest) -> str: +def framework(request: pytest.FixtureRequest) -> str: return typing.cast("str", request.param) -def build_version_docs_config( - suppressed_operations: tuple[OpenApiOperationSelector, ...] = (), - operation_versions: tuple[OpenApiOperationVersionOverride, ...] = (), +def version_docs( + *, + overrides: tuple[OpenApiOperationVersionOverride, ...] = (), ) -> OpenApiVersionDocsConfig: return OpenApiVersionDocsConfig( - enabled=True, vendor_media_type="application/vnd.real-api+json", supported_versions=("2026-01", "release-candidate"), - suppressed_operations=suppressed_operations, - operation_versions=operation_versions, + operation_versions=overrides, ) -def build_expected_description(description: object, supported_versions: tuple[str, ...]) -> str: - version_documentation = "\n".join( - f"- `application/vnd.real-api+json; version={version}`." for version in supported_versions - ) - if len(supported_versions) == 1: - version_documentation = version_documentation.removeprefix("- ").removesuffix(".") + "." - version_documentation = f"Supported API version: {version_documentation}" - else: - version_documentation = f"Supported API versions:\n{version_documentation}" - if not isinstance(description, str) or not description: - return version_documentation - return f"{description}\n\n{version_documentation}" +def build_application( + framework: str, + config: OpenApiVersionDocsConfig | None, + security_schemes: dict[str, OpenApiSecurityScheme] | None = None, + startup_hook: MagicMock | None = None, + shutdown_hook: MagicMock | None = None, +) -> BuiltApplication: + if framework == "fastapi": + fastapi_application = FastApiBootstrapper( + FastApiSettings( + service_debug=False, + security_schemes=security_schemes or {}, + openapi_version_docs=config, + ) + ).bootstrap() + service_auth = HTTPBearer() + @fastapi_application.get(TARGET_PATH, description="List widgets", dependencies=[fastapi.Security(service_auth)]) + async def fastapi_list_widgets() -> dict[str, str]: + return {"status": "ok"} -def build_expected_documented_schema( - original_schema: dict[str, typing.Any], - configuration: OpenApiVersionDocsConfig, -) -> dict[str, typing.Any]: - expected_schema: typing.Final = copy.deepcopy(original_schema) - suppressed_pairs: typing.Final = { - (selector.path, selector.method) for selector in configuration.suppressed_operations - } - overrides: typing.Final = { - (override.path, override.method): override.supported_versions for override in configuration.operation_versions - } - for path, path_item in expected_schema["paths"].items(): - if not isinstance(path_item, dict): - continue - for method, operation in path_item.items(): - if method not in {"delete", "get", "head", "options", "patch", "post", "put", "trace"}: - continue - if (path, method) in suppressed_pairs or not isinstance(operation, dict): - continue - supported_versions = overrides.get((path, method), configuration.supported_versions) - operation["description"] = build_expected_description(operation.get("description"), supported_versions) - operation["x-accept-versioning"] = { - "header": "Accept", - "mediaType": configuration.vendor_media_type, - "parameter": "version", - "supportedVersions": list(supported_versions), - } - return expected_schema + @fastapi_application.post(TARGET_PATH, description="Create widget") + async def fastapi_create_widget() -> dict[str, str]: + return {"status": "created"} + @fastapi_application.get(HEALTH_PATH, description="Service health") + async def fastapi_service_health() -> dict[str, str]: + return {"status": "ok"} -def build_litestar_application( - version_docs_config: OpenApiVersionDocsConfig | None, - startup_hook: MagicMock | None = None, - shutdown_hook: MagicMock | None = None, - security_schemes: dict[str, OpenApiSecurityScheme] | None = None, -) -> BuiltSwaggerApplication: - @get(TARGET_PATH, description=GET_DESCRIPTION, security=[{"ServiceAuth": []}]) - async def list_widgets() -> ServiceOwnedResponse: - return ServiceOwnedResponse(status="ok") + @fastapi_application.get(NO_DESCRIPTION_PATH) + async def fastapi_without_description() -> dict[str, str]: + return {"status": "ok"} + + return BuiltApplication(framework, fastapi_application) - @post(TARGET_PATH, description=POST_DESCRIPTION) - async def create_widget() -> ServiceOwnedResponse: - return ServiceOwnedResponse(status="created") + @get(TARGET_PATH, description="List widgets", security=[{"ServiceAuth": []}]) + async def litestar_list_widgets() -> ServiceResponse: + return ServiceResponse(status="ok") - @get(UNCHANGED_PATH, description="Service health") - async def service_health() -> dict[str, str]: + @post(TARGET_PATH, description="Create widget") + async def litestar_create_widget() -> ServiceResponse: + return ServiceResponse(status="created") + + @get(HEALTH_PATH, description="Service health") + async def litestar_service_health() -> dict[str, str]: return {"status": "ok"} - @get(MISSING_DESCRIPTION_PATH) - async def list_widgets_without_description() -> dict[str, str]: + @get(NO_DESCRIPTION_PATH) + async def litestar_without_description() -> dict[str, str]: return {"status": "ok"} - renderer: typing.Final = SwaggerRenderPlugin() - service_owned_components: typing.Final = litestar_openapi.Components( - security_schemes={ - "GlobalAuth": litestar_openapi.SecurityScheme(type="http", scheme="bearer"), - "ServiceAuth": litestar_openapi.SecurityScheme(type="http", scheme="bearer"), - }, - ) - application: typing.Final = ( + renderer = SwaggerRenderPlugin() + litestar_application = ( LitestarBootstrapper( LitestarSettings( service_debug=False, security_schemes=security_schemes or {}, - openapi_version_docs=version_docs_config, + openapi_version_docs=config, ) ) .configure_application( LitestarConfig( - route_handlers=[list_widgets, create_widget, service_health, list_widgets_without_description], + route_handlers=[ + litestar_list_widgets, + litestar_create_widget, + litestar_service_health, + litestar_without_description, + ], on_startup=[startup_hook] if startup_hook is not None else [], on_shutdown=[shutdown_hook] if shutdown_hook is not None else [], openapi_config=openapi.OpenAPIConfig( title="Service API", version="1.0.0", - components=service_owned_components, + components=litestar_openapi.Components( + security_schemes={ + "GlobalAuth": litestar_openapi.SecurityScheme(type="http", scheme="bearer"), + "ServiceAuth": litestar_openapi.SecurityScheme(type="http", scheme="bearer"), + } + ), security=[{"GlobalAuth": []}], render_plugins=[renderer], ), @@ -184,203 +197,190 @@ async def list_widgets_without_description() -> dict[str, str]: ) .bootstrap() ) - return BuiltSwaggerApplication("litestar", application, "/schema/openapi.json", renderer) + return BuiltApplication(framework, litestar_application, renderer) -def build_fastapi_application( - version_docs_config: OpenApiVersionDocsConfig | None, - security_schemes: dict[str, OpenApiSecurityScheme] | None = None, -) -> BuiltSwaggerApplication: - application: typing.Final = FastApiBootstrapper( - FastApiSettings( - service_debug=False, - security_schemes=security_schemes or {}, - openapi_version_docs=version_docs_config, - ) - ).bootstrap() - service_authentication: typing.Final = HTTPBearer() - - @application.get(TARGET_PATH, description=GET_DESCRIPTION, dependencies=[fastapi.Security(service_authentication)]) - async def list_widgets() -> dict[str, str]: - return {"status": "ok"} +def test_version_docs_change_only_selected_operations_and_match_served_schema(framework: str) -> None: + baseline = build_application(framework, None).schema() + application = build_application( + framework, + version_docs( + overrides=(OpenApiOperationVersionOverride(path=TARGET_PATH, method="get", supported_versions=()),), + ), + ) - @application.post(TARGET_PATH, description=POST_DESCRIPTION) - async def create_widget() -> dict[str, str]: - return {"status": "created"} + schema = application.schema() + comparable_schema = copy.deepcopy(schema) + comparable_baseline = copy.deepcopy(baseline) + for path, method in ((TARGET_PATH, "post"), (HEALTH_PATH, "get"), (NO_DESCRIPTION_PATH, "get")): + comparable_schema["paths"][path][method].pop("description") + comparable_schema["paths"][path][method].pop("x-accept-versioning") + comparable_baseline["paths"][path][method].pop("description", None) - @application.get(UNCHANGED_PATH, description="Service health") - async def service_health() -> dict[str, str]: - return {"status": "ok"} + assert application.schema() == schema + assert application.served_schema() == schema + assert comparable_schema == comparable_baseline + assert schema["components"] == baseline["components"] + assert schema.get("security") == baseline.get("security") + assert schema["paths"][TARGET_PATH]["get"] == baseline["paths"][TARGET_PATH]["get"] + assert schema["paths"][TARGET_PATH]["post"]["description"] == f"Create widget\n\n{VERSION_TEXT}" + assert schema["paths"][HEALTH_PATH]["get"]["description"] == f"Service health\n\n{VERSION_TEXT}" + assert schema["paths"][NO_DESCRIPTION_PATH]["get"]["description"] == VERSION_TEXT + assert schema["paths"][TARGET_PATH]["post"]["x-accept-versioning"] == EXTENSION + application.create_widget() + + +def test_absent_version_docs_leave_schema_and_runtime_unchanged( + framework: str, +) -> None: + baseline = build_application(framework, None) + application = build_application(framework, None) - @application.get(MISSING_DESCRIPTION_PATH) - async def list_widgets_without_description() -> dict[str, str]: - return {"status": "ok"} + assert application.schema() == baseline.schema() + assert application.served_schema() == baseline.schema() + application.create_widget() - return BuiltSwaggerApplication("fastapi", application, "/openapi.json") +def test_empty_operation_override_skips_malformed_service_owned_metadata(framework: str) -> None: + configuration = version_docs( + overrides=(OpenApiOperationVersionOverride(path=TARGET_PATH, method="get", supported_versions=()),), + ) + if framework == "fastapi": + application, schema = custom_fastapi_application() + operation = schema["paths"][TARGET_PATH]["get"] + operation.update({"description": 1, "x-accept-versioning": {"header": "X-Service-Version"}}) + + FastApiSwaggerInstrument(SwaggerConfig(openapi_version_docs=configuration)).bootstrap_after(application) + + assert application.openapi() is schema + assert schema["paths"][TARGET_PATH]["get"] is operation + return + + built = build_application("litestar", None) + assert isinstance(built.application, litestar.Litestar) + assert built.application.openapi_schema is not None + assert built.application.openapi_schema.paths is not None + path_item = built.application.openapi_schema.paths[TARGET_PATH] + assert path_item.get is not None -def build_application( - framework: str, - version_docs_config: OpenApiVersionDocsConfig | None, - security_schemes: dict[str, OpenApiSecurityScheme] | None = None, -) -> BuiltSwaggerApplication: - if framework == "litestar": - return build_litestar_application(version_docs_config, security_schemes=security_schemes) - return build_fastapi_application(version_docs_config, security_schemes) + @dataclasses.dataclass + class ServiceOperation(litestar_openapi.Operation): + accept_versioning: dict[str, str | list[str]] | None = dataclasses.field( + default=None, + metadata={"alias": "x-accept-versioning"}, + ) + fields = {field.name: getattr(path_item.get, field.name) for field in dataclasses.fields(path_item.get)} + operation = ServiceOperation(**fields, accept_versioning={"header": "X-Service-Version"}) + operation.description = 1 # type: ignore[assignment] # Deliberately invalid service-owned schema. + path_item.get = operation -def request_schema(application: BuiltSwaggerApplication) -> dict[str, typing.Any]: - if application.framework == "litestar": - assert isinstance(application.application, litestar.Litestar) - with LitestarTestClient(app=application.application) as test_client: - response = test_client.get(application.schema_path) - else: - assert isinstance(application.application, fastapi.FastAPI) - with FastAPITestClient(app=application.application) as test_client: - response = test_client.get(application.schema_path) - assert response.status_code == status_codes.HTTP_200_OK - return typing.cast("dict[str, typing.Any]", response.json()) - - -def create_widget(application: BuiltSwaggerApplication) -> None: - expected_status: int - if application.framework == "litestar": - assert isinstance(application.application, litestar.Litestar) - with LitestarTestClient(app=application.application) as test_client: - response = test_client.post(TARGET_PATH) - expected_status = status_codes.HTTP_201_CREATED - else: - assert isinstance(application.application, fastapi.FastAPI) - with FastAPITestClient(app=application.application) as test_client: - response = test_client.post(TARGET_PATH) - expected_status = status_codes.HTTP_200_OK - assert response.status_code == expected_status - assert response.headers["content-type"] == "application/json" - assert response.json() == {"status": "created"} - - -def test_production_version_docs_only_change_expected_operation_descriptions(swagger_framework: str) -> None: - suppressed_operations: typing.Final = (OpenApiOperationSelector(path=TARGET_PATH, method="get"),) - baseline_application: typing.Final = build_application(swagger_framework, None) - original_schema: typing.Final = baseline_application.schema() - version_docs_config: typing.Final = build_version_docs_config(suppressed_operations) - application: typing.Final = build_application(swagger_framework, version_docs_config) - expected_schema: typing.Final = build_expected_documented_schema(original_schema, version_docs_config) - - early_schema: typing.Final = application.schema() - repeated_schema: typing.Final = application.schema() - first_served_schema: typing.Final = request_schema(application) - second_served_schema: typing.Final = request_schema(application) - - assert early_schema == expected_schema - assert repeated_schema == expected_schema - assert first_served_schema == expected_schema - assert second_served_schema == expected_schema - assert early_schema["components"] == original_schema["components"] - assert early_schema.get("security") == original_schema.get("security") - assert early_schema["paths"][TARGET_PATH]["get"] == original_schema["paths"][TARGET_PATH]["get"] - assert early_schema["paths"][TARGET_PATH]["post"]["description"] == build_expected_description( - POST_DESCRIPTION, - version_docs_config.supported_versions, - ) - assert early_schema["paths"][MISSING_DESCRIPTION_PATH]["get"]["description"] == VERSION_DOCUMENTATION - assert early_schema["paths"][TARGET_PATH]["post"]["description"].count(VERSION_DOCUMENTATION) == 1 - assert "x-accept-versioning" not in early_schema["paths"][TARGET_PATH]["get"] - assert early_schema["paths"][TARGET_PATH]["post"]["x-accept-versioning"] == { - "header": "Accept", - "mediaType": "application/vnd.real-api+json", - "parameter": "version", - "supportedVersions": ["2026-01", "release-candidate"], - } - create_widget(application) + LitestarSwaggerInstrument(SwaggerConfig(openapi_version_docs=configuration)).bootstrap_after(built.application) + assert path_item.get is operation -@pytest.mark.parametrize("version_docs_config", [None, OpenApiVersionDocsConfig()]) -def test_absent_or_disabled_version_docs_preserve_the_whole_schema( - swagger_framework: str, - version_docs_config: OpenApiVersionDocsConfig | None, -) -> None: - baseline_application: typing.Final = build_application(swagger_framework, None) - baseline_schema: typing.Final = baseline_application.schema() - application: typing.Final = build_application(swagger_framework, version_docs_config) - assert application.schema() == baseline_schema - assert request_schema(application) == baseline_schema - create_widget(application) +def test_version_docs_do_not_create_absent_security_scheme_containers(framework: str) -> None: + if framework == "fastapi": + application, schema = custom_fastapi_application() + schema.pop("components") + FastApiSwaggerInstrument(SwaggerConfig(openapi_version_docs=version_docs())).bootstrap_after(application) + assert application.openapi() is schema + assert "components" not in schema + return -def test_production_version_docs_use_singular_format_for_one_supported_version(swagger_framework: str) -> None: - configuration: typing.Final = OpenApiVersionDocsConfig( - enabled=True, - vendor_media_type="application/vnd.real-api+json", - supported_versions=("2026-01",), - ) - application: typing.Final = build_application(swagger_framework, configuration) + built = build_application("litestar", None) + assert isinstance(built.application, litestar.Litestar) + assert built.application.openapi_schema is not None + built.application.openapi_schema.components.security_schemes = None + LitestarSwaggerInstrument(SwaggerConfig(openapi_version_docs=version_docs())).bootstrap_after(built.application) - assert application.schema()["paths"][TARGET_PATH]["post"]["description"] == ( - f"{POST_DESCRIPTION}\n\nSupported API version: `application/vnd.real-api+json; version=2026-01`." - ) + assert built.application.openapi_schema.components.security_schemes is None -def test_operation_version_override_and_accept_requests_do_not_change_runtime(swagger_framework: str) -> None: - version_docs_config: typing.Final = build_version_docs_config( - operation_versions=( - OpenApiOperationVersionOverride( - path=TARGET_PATH, - method="post", +@pytest.mark.parametrize( + ("configuration", "expected_description", "expected_extension"), + [ + ( + OpenApiVersionDocsConfig( + vendor_media_type="application/vnd.real-api+json", supported_versions=("2027-01",), ), + "Create widget\n\nSupported API version: `application/vnd.real-api+json; version=2027-01`.", + { + "header": "Accept", + "mediaType": "application/vnd.real-api+json", + "parameter": "version", + "supportedVersions": ["2027-01"], + }, + ), + ( + version_docs( + overrides=( + OpenApiOperationVersionOverride( + path=TARGET_PATH, + method="post", + supported_versions=("2027-01",), + ), + ) + ), + "Create widget\n\nSupported API version: `application/vnd.real-api+json; version=2027-01`.", + { + "header": "Accept", + "mediaType": "application/vnd.real-api+json", + "parameter": "version", + "supportedVersions": ["2027-01"], + }, ), - ) - baseline_application: typing.Final = build_application(swagger_framework, None) - baseline_schema: typing.Final = baseline_application.schema() - application: typing.Final = build_application(swagger_framework, version_docs_config) - schema: typing.Final = application.schema() - - assert schema["paths"][TARGET_PATH]["post"]["x-accept-versioning"] == { - "header": "Accept", - "mediaType": "application/vnd.real-api+json", - "parameter": "version", - "supportedVersions": ["2027-01"], - } - assert schema["paths"][TARGET_PATH]["post"]["description"] == build_expected_description( - POST_DESCRIPTION, - ("2027-01",), - ) - assert schema["paths"][TARGET_PATH]["post"].get("parameters") == baseline_schema["paths"][TARGET_PATH]["post"].get( - "parameters" - ) - assert schema["paths"][TARGET_PATH]["post"].get("responses") == baseline_schema["paths"][TARGET_PATH]["post"].get( - "responses" + ], +) +def test_single_global_or_operation_override_version_and_accept_header_do_not_change_runtime( + framework: str, + configuration: OpenApiVersionDocsConfig, + expected_description: str, + expected_extension: dict[str, str | list[str]], +) -> None: + baseline = build_application(framework, None).schema() + application = build_application( + framework, + configuration, ) - for headers in ({}, {"Accept": "application/vnd.real-api+json; version=2027-01"}): - if isinstance(application.application, litestar.Litestar): - with LitestarTestClient(app=application.application) as test_client: - response = test_client.post(TARGET_PATH, headers=headers) - expected_status = status_codes.HTTP_201_CREATED - else: - with FastAPITestClient(app=application.application) as test_client: - response = test_client.post(TARGET_PATH, headers=headers) - expected_status = status_codes.HTTP_200_OK - assert response.status_code == expected_status - assert response.headers["content-type"] == "application/json" - assert response.json() == {"status": "created"} + operation = application.schema()["paths"][TARGET_PATH]["post"] + assert operation["description"] == expected_description + assert operation["x-accept-versioning"] == expected_extension + assert operation.get("parameters") == baseline["paths"][TARGET_PATH]["post"].get("parameters") + assert operation.get("responses") == baseline["paths"][TARGET_PATH]["post"].get("responses") + application.create_widget() + application.create_widget({"Accept": "application/vnd.real-api+json; version=2027-01"}) -def build_security_schemes() -> dict[str, OpenApiSecurityScheme]: +def configured_schemes() -> dict[str, OpenApiSecurityScheme]: return { "httpAuth": OpenApiHttpSecurityScheme(scheme="bearer", bearer_format="JWT"), "apiKeyAuth": OpenApiApiKeySecurityScheme(name="X-API-Key", location="header"), "oauth": OpenApiOAuth2SecurityScheme( flows=OpenApiOAuthFlows( - implicit=OpenApiOAuthFlow(authorization_url="/authorize", scopes={"read": "Read widgets"}), - resource_owner=OpenApiOAuthFlow(token_url="/token", scopes={"write": "Write widgets"}), # noqa: S106 - client_credentials=OpenApiOAuthFlow(token_url="/client-token", scopes={}), # noqa: S106 + implicit=OpenApiOAuthFlow( + authorization_url="/implicit-authorize", + refresh_url="/implicit-refresh", + scopes={"read": "Read widgets"}, + ), + resource_owner=OpenApiOAuthFlow( + token_url="/password-token", # noqa: S106 + refresh_url="/password-refresh", + scopes={"write": "Write widgets"}, + ), + client_credentials=OpenApiOAuthFlow( + token_url="/client-token", # noqa: S106 + refresh_url="/client-refresh", + scopes={"service": "Service access"}, + ), authorization_code=OpenApiOAuthFlow( authorization_url="/code-authorize", token_url="/code-token", # noqa: S106 - refresh_url="/refresh", + refresh_url="/code-refresh", scopes={"admin": "Administer widgets"}, ), ) @@ -389,407 +389,252 @@ def build_security_schemes() -> dict[str, OpenApiSecurityScheme]: } -def test_security_schemes_preserve_service_owned_schema_and_security(swagger_framework: str) -> None: - security_schemes: typing.Final = build_security_schemes() - baseline_application: typing.Final = build_application(swagger_framework, None) - baseline_schema: typing.Final = baseline_application.schema() - application: typing.Final = build_application(swagger_framework, None, security_schemes) - schema: typing.Final = application.schema() +def test_security_schemes_preserve_service_owned_security_and_combine_with_version_docs(framework: str) -> None: + schemes = configured_schemes() + baseline = build_application(framework, None).schema() + application = build_application(framework, version_docs(), schemes) + + schema = application.schema() + comparable_schema = copy.deepcopy(schema) + for scheme_name in schemes: + comparable_schema["components"]["securitySchemes"].pop(scheme_name) + for path, method in VERSIONED_OPERATIONS: + comparable_schema["paths"][path][method].pop("description") + comparable_schema["paths"][path][method].pop("x-accept-versioning") + comparable_baseline = copy.deepcopy(baseline) + for path, method in VERSIONED_OPERATIONS: + comparable_baseline["paths"][path][method].pop("description", None) - assert schema["components"].get("schemas") == baseline_schema["components"].get("schemas") + assert comparable_schema == comparable_baseline assert schema["components"]["securitySchemes"] == { - **baseline_schema["components"]["securitySchemes"], - **serialize_security_schemes(security_schemes), + **( + {"HTTPBearer": {"type": "http", "scheme": "bearer"}} + if framework == "fastapi" + else { + "GlobalAuth": {"type": "http", "scheme": "bearer"}, + "ServiceAuth": {"type": "http", "scheme": "bearer"}, + } + ), + "httpAuth": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}, + "apiKeyAuth": {"type": "apiKey", "name": "X-API-Key", "in": "header"}, + "oauth": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "/implicit-authorize", + "refreshUrl": "/implicit-refresh", + "scopes": {"read": "Read widgets"}, + }, + "password": { + "tokenUrl": "/password-token", + "refreshUrl": "/password-refresh", + "scopes": {"write": "Write widgets"}, + }, + "clientCredentials": { + "tokenUrl": "/client-token", + "refreshUrl": "/client-refresh", + "scopes": {"service": "Service access"}, + }, + "authorizationCode": { + "authorizationUrl": "/code-authorize", + "tokenUrl": "/code-token", + "refreshUrl": "/code-refresh", + "scopes": {"admin": "Administer widgets"}, + }, + }, + }, + "oidc": {"type": "openIdConnect", "openIdConnectUrl": "/.well-known/openid-configuration"}, } - assert schema.get("security") == baseline_schema.get("security") - assert schema["paths"][TARGET_PATH]["get"].get("security") == baseline_schema["paths"][TARGET_PATH]["get"].get( - "security" - ) - assert application.schema() == schema + assert schema.get("security") == baseline.get("security") + assert schema["paths"][TARGET_PATH]["get"].get("security") == baseline["paths"][TARGET_PATH]["get"].get("security") + assert schema["paths"][TARGET_PATH]["post"]["description"] == f"Create widget\n\n{VERSION_TEXT}" + assert schema["paths"][TARGET_PATH]["post"]["x-accept-versioning"] == EXTENSION + assert application.served_schema() == schema -def test_security_schemes_combine_with_accept_version_documentation(swagger_framework: str) -> None: - security_schemes: typing.Final = build_security_schemes() - application: typing.Final = build_application( - swagger_framework, - build_version_docs_config(), - security_schemes, - ) - schema: typing.Final = application.schema() - - assert schema["components"]["securitySchemes"].items() >= serialize_security_schemes(security_schemes).items() - assert schema["paths"][TARGET_PATH]["post"]["x-accept-versioning"] == { - "header": "Accept", - "mediaType": "application/vnd.real-api+json", - "parameter": "version", - "supportedVersions": ["2026-01", "release-candidate"], +def custom_fastapi_application() -> tuple[fastapi.FastAPI, dict[str, typing.Any]]: + schema: dict[str, typing.Any] = { + "openapi": "3.1.0", + "info": {"title": "Service API", "version": "1.0.0"}, + "paths": {TARGET_PATH: {"get": {"description": "List widgets"}, "post": {"description": "Create widget"}}}, + "components": {"securitySchemes": {}}, } + application = fastapi.FastAPI() + application.openapi = lambda: schema # type: ignore[method-assign] # Public custom OpenAPI hook. + return application, schema @pytest.mark.parametrize( - ("failure", "error"), + ("mutation", "error"), [ - ("conflicting_extension", "x-accept-versioning conflicts"), - ("non_string_description", "has a non-string description"), + (lambda operation: operation.update({"x-accept-versioning": {"header": "X-Service-Version"}}), "conflicts"), + (lambda operation: operation.update({"description": 1}), "non-string description"), ], ) -def test_fastapi_combined_openapi_augmentation_is_atomic_and_retryable(failure: str, error: str) -> None: - service_schema: dict[str, typing.Any] = { - "openapi": "3.1.0", - "info": {"title": "Service API", "version": "1.0.0"}, - "paths": { - TARGET_PATH: { - "get": {"description": GET_DESCRIPTION}, - "post": {"description": POST_DESCRIPTION}, - } - }, - "components": {"securitySchemes": {}}, - } - failing_operation = service_schema["paths"][TARGET_PATH]["post"] - if failure == "conflicting_extension": - failing_operation["x-accept-versioning"] = {"header": "X-Service-Version"} - else: - failing_operation["description"] = 1 - baseline_schema: typing.Final = copy.deepcopy(service_schema) - application: typing.Final = fastapi.FastAPI() - application.openapi = lambda: service_schema # type: ignore[method-assign] # FastAPI's public custom OpenAPI hook. - instrument: typing.Final = FastApiSwaggerInstrument( +def test_fastapi_preflight_is_atomic_and_retryable( + mutation: typing.Callable[[dict[str, typing.Any]], None], + error: str, +) -> None: + application, schema = custom_fastapi_application() + mutation(schema["paths"][TARGET_PATH]["post"]) + baseline = copy.deepcopy(schema) + instrument = FastApiSwaggerInstrument( SwaggerConfig( security_schemes={"serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer")}, - openapi_version_docs=build_version_docs_config(), + openapi_version_docs=version_docs(), ) ) instrument.bootstrap_after(application) with pytest.raises(ValueError, match=error): application.openapi() - - assert service_schema == baseline_schema + assert schema == baseline assert application.openapi_schema is None - if failure == "conflicting_extension": - failing_operation.pop("x-accept-versioning") - else: - failing_operation["description"] = POST_DESCRIPTION - first_schema: typing.Final = application.openapi() - second_schema: typing.Final = application.openapi() - with FastAPITestClient(application) as client: - served_schema: typing.Final = client.get("/openapi.json") + schema["paths"][TARGET_PATH]["post"] = {"description": "Create widget"} + assert application.openapi() is schema + assert schema["components"]["securitySchemes"]["serviceAuth"] == {"type": "http", "scheme": "bearer"} + assert schema["paths"][TARGET_PATH]["get"]["x-accept-versioning"] == EXTENSION - assert first_schema is second_schema is service_schema - assert first_schema["components"]["securitySchemes"]["serviceAuth"] == {"type": "http", "scheme": "bearer"} - assert first_schema["paths"][TARGET_PATH]["get"]["x-accept-versioning"]["header"] == "Accept" - assert served_schema.status_code == status_codes.HTTP_200_OK - assert served_schema.json() == first_schema +def test_fastapi_default_openapi_cache_retries_after_correcting_a_conflict() -> None: + application = fastapi.FastAPI() -def test_fastapi_default_openapi_cache_retries_after_a_corrected_conflict() -> None: - application: typing.Final = fastapi.FastAPI() - - @application.get( - TARGET_PATH, - description=GET_DESCRIPTION, - openapi_extra={"x-accept-versioning": {"header": "X-Service-Version"}}, - ) + @application.get(TARGET_PATH, openapi_extra={"x-accept-versioning": {"header": "X-Service-Version"}}) async def list_widgets() -> dict[str, str]: return {"status": "ok"} FastApiSwaggerInstrument( SwaggerConfig( security_schemes={"serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer")}, - openapi_version_docs=build_version_docs_config(), + openapi_version_docs=version_docs(), ) ).bootstrap_after(application) with pytest.raises(ValueError, match="x-accept-versioning conflicts"): application.openapi() - - cached_schema: typing.Final = application.openapi_schema - assert cached_schema is not None - assert "serviceAuth" not in cached_schema.get("components", {}).get("securitySchemes", {}) - assert cached_schema["paths"][TARGET_PATH]["get"]["x-accept-versioning"] == {"header": "X-Service-Version"} - - cached_schema["paths"][TARGET_PATH]["get"].pop("x-accept-versioning") - corrected_schema: typing.Final = application.openapi() - - assert corrected_schema is cached_schema - assert corrected_schema["components"]["securitySchemes"]["serviceAuth"] == { - "type": "http", - "scheme": "bearer", - } - assert corrected_schema["paths"][TARGET_PATH]["get"]["x-accept-versioning"]["header"] == "Accept" - - -@pytest.mark.parametrize( - ("failure", "error"), - [ - ("conflicting_extension", "x-accept-versioning conflicts"), - ("non_string_description", "has a non-string description"), - ], -) -def test_litestar_combined_openapi_augmentation_is_atomic_and_retryable(failure: str, error: str) -> None: - built_application: typing.Final = build_litestar_application(None) - application: typing.Final = built_application.application - assert isinstance(application, litestar.Litestar) assert application.openapi_schema is not None - assert application.openapi_schema.paths is not None - path_item: typing.Final = application.openapi_schema.paths[TARGET_PATH] - assert isinstance(path_item, litestar_openapi.PathItem) - assert path_item.get is not None - assert path_item.post is not None - original_get: typing.Final = path_item.get - instrument: typing.Final = LitestarSwaggerInstrument( - SwaggerConfig( - security_schemes={"serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer")}, - openapi_version_docs=build_version_docs_config(), - ) - ) - if failure == "conflicting_extension": - path_item.post = instrument._add_accept_versioning_extension( # noqa: SLF001 - setup through the instrument. - path_item.post, - {"header": "X-Service-Version"}, + application.openapi_schema["paths"][TARGET_PATH]["get"].pop("x-accept-versioning") + + corrected_schema = application.openapi() + assert corrected_schema["components"]["securitySchemes"]["serviceAuth"] == {"type": "http", "scheme": "bearer"} + assert corrected_schema["paths"][TARGET_PATH]["get"]["x-accept-versioning"] == EXTENSION + + +def test_security_scheme_collisions_are_atomic_for_both_frameworks(framework: str) -> None: + if framework == "fastapi": + application, schema = custom_fastapi_application() + schema["components"]["securitySchemes"] = { + "matching": {"type": "http", "scheme": "bearer"}, + "conflict": {"type": "http", "scheme": "basic"}, + } + FastApiSwaggerInstrument( + SwaggerConfig(security_schemes={"matching": OpenApiHttpSecurityScheme(scheme="bearer")}) + ).bootstrap_after(application) + assert application.openapi() is schema + instrument = FastApiSwaggerInstrument( + SwaggerConfig( + security_schemes={ + "insert": OpenApiApiKeySecurityScheme(name="X-API-Key", location="header"), + "conflict": OpenApiHttpSecurityScheme(scheme="bearer"), + } + ) ) - else: - path_item.post.description = 1 # type: ignore[assignment] # Deliberately invalid service-owned schema. - baseline_schema: typing.Final = copy.deepcopy(application.openapi_schema.to_schema()) - with pytest.raises(ValueError, match=error): instrument.bootstrap_after(application) - - assert application.openapi_schema.to_schema() == baseline_schema - assert path_item.get is original_get - assert application.openapi_schema.components.security_schemes is not None - assert "serviceAuth" not in application.openapi_schema.components.security_schemes - if failure == "conflicting_extension": - assert path_item.post is not None - extension_field = next( - field - for field in dataclasses.fields(path_item.post) - if field.metadata.get("alias") == "x-accept-versioning" - ) - object.__setattr__(path_item.post, extension_field.name, None) - else: - path_item.post.description = POST_DESCRIPTION - - instrument.bootstrap_after(application) - documented_get: typing.Final = path_item.get - instrument.bootstrap_after(application) - assert path_item.get is documented_get - with LitestarTestClient(application) as client: - first_served_schema: typing.Final = client.get("/schema/openapi.json") - second_served_schema: typing.Final = client.get("/schema/openapi.json") - - assert application.openapi_schema.to_schema()["components"]["securitySchemes"]["serviceAuth"] == { - "type": "http", - "scheme": "bearer", - } - assert first_served_schema.status_code == second_served_schema.status_code == status_codes.HTTP_200_OK - assert first_served_schema.json() == second_served_schema.json() == application.openapi_schema.to_schema() - - -def test_fastapi_security_scheme_collisions_are_atomic_and_identical_schemes_are_retained() -> None: - expected_scheme: typing.Final = {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"} - service_schema: dict[str, typing.Any] = { - "openapi": "3.1.0", - "info": {"title": "Service API", "version": "1.0.0"}, - "paths": {}, - "components": { - "schemas": {"ServiceOwned": {"type": "object"}}, - "securitySchemes": {"matching": expected_scheme, "conflicting": {"type": "http", "scheme": "basic"}}, - }, - } - application: typing.Final = fastapi.FastAPI() - application.openapi = lambda: service_schema # type: ignore[method-assign] # FastAPI's public custom OpenAPI hook. - instrument: typing.Final = FastApiSwaggerInstrument( - SwaggerConfig( - security_schemes={ - "matching": OpenApiHttpSecurityScheme(scheme="bearer", bearer_format="JWT"), - } - ) - ) - instrument.bootstrap_after(application) - - assert application.openapi() is service_schema - assert service_schema["components"] == { - "schemas": {"ServiceOwned": {"type": "object"}}, - "securitySchemes": {"matching": expected_scheme, "conflicting": {"type": "http", "scheme": "basic"}}, - } - - FastApiSwaggerInstrument( - SwaggerConfig( - security_schemes={ - "insert": OpenApiApiKeySecurityScheme(name="X-API-Key", location="header"), - "conflicting": OpenApiHttpSecurityScheme(scheme="bearer"), - } - ) - ).bootstrap_after(application) - with pytest.raises(ValueError, match="security scheme 'conflicting' conflicts"): - application.openapi() - assert "insert" not in service_schema["components"]["securitySchemes"] - - -def test_litestar_security_scheme_collisions_are_atomic() -> None: - application: typing.Final = build_litestar_application(None).application - assert isinstance(application, litestar.Litestar) - assert application.openapi_schema is not None - assert application.openapi_schema.components.security_schemes is not None - application.openapi_schema.components.security_schemes["conflicting"] = litestar_openapi.SecurityScheme( + with pytest.raises(ValueError, match="security scheme 'conflict' conflicts"): + application.openapi() + assert "insert" not in schema["components"]["securitySchemes"] + return + + litestar_application = build_application("litestar", None).application + assert isinstance(litestar_application, litestar.Litestar) + assert litestar_application.openapi_schema is not None + assert litestar_application.openapi_schema.components.security_schemes is not None + matching_scheme = litestar_openapi.SecurityScheme(type="http", scheme="bearer") + litestar_application.openapi_schema.components.security_schemes["matching"] = matching_scheme + LitestarSwaggerInstrument( + SwaggerConfig(security_schemes={"matching": OpenApiHttpSecurityScheme(scheme="bearer")}) + ).bootstrap_after(litestar_application) + assert litestar_application.openapi_schema.components.security_schemes["matching"] is matching_scheme + litestar_application.openapi_schema.components.security_schemes["conflict"] = litestar_openapi.SecurityScheme( type="http", scheme="basic", ) - - with pytest.raises(ValueError, match="security scheme 'conflicting' conflicts"): + with pytest.raises(ValueError, match="security scheme 'conflict' conflicts"): LitestarSwaggerInstrument( SwaggerConfig( security_schemes={ "insert": OpenApiApiKeySecurityScheme(name="X-API-Key", location="header"), - "conflicting": OpenApiHttpSecurityScheme(scheme="bearer"), + "conflict": OpenApiHttpSecurityScheme(scheme="bearer"), } ) - ).bootstrap_after(application) - assert "insert" not in application.openapi_schema.components.security_schemes - - -@pytest.mark.parametrize("settings_type", [FastApiSettings, LitestarSettings]) -def test_openapi_settings_validate_security_schemes_and_operation_versions( - settings_type: type[FastApiSettings] | type[LitestarSettings], -) -> None: - settings: typing.Final = settings_type( - security_schemes={"serviceBearer": {"type": "http", "scheme": "bearer"}}, - openapi_version_docs={ - "enabled": True, - "vendor_media_type": "application/vnd.real-api+json", - "supported_versions": ("2026-01",), - "operation_versions": ({"path": TARGET_PATH, "method": "post", "supported_versions": ("2027-01",)},), - }, - ) + ).bootstrap_after(litestar_application) + assert "insert" not in litestar_application.openapi_schema.components.security_schemes - assert settings.security_schemes == {"serviceBearer": OpenApiHttpSecurityScheme(scheme="bearer")} - assert settings.openapi_version_docs is not None - assert settings.openapi_version_docs.operation_versions == ( - OpenApiOperationVersionOverride(path=TARGET_PATH, method="post", supported_versions=("2027-01",)), - ) - - with pytest.raises(ValueError, match="must contain at least one"): - OpenApiOperationVersionOverride(path=TARGET_PATH, method="post", supported_versions=()) - - with pytest.raises(ValueError, match="duplicate path and method pairs"): - OpenApiVersionDocsConfig( - enabled=True, - vendor_media_type="application/vnd.real-api+json", - supported_versions=("2026-01",), - operation_versions=( - OpenApiOperationVersionOverride(path=TARGET_PATH, method="post", supported_versions=("2027-01",)), - OpenApiOperationVersionOverride(path=TARGET_PATH, method="post", supported_versions=("2028-01",)), - ), - ) +def test_fastapi_preserves_custom_generator_calls_and_errors() -> None: + application, schema = custom_fastapi_application() + calls = 0 -def test_fastapi_version_extension_conflict_is_not_overwritten() -> None: - application: typing.Final = fastapi.FastAPI() - application.openapi = lambda: { # type: ignore[method-assign] # FastAPI's public custom OpenAPI hook. - "openapi": "3.1.0", - "info": {"title": "Service API", "version": "1.0.0"}, - "paths": { - TARGET_PATH: { - "get": { - "description": GET_DESCRIPTION, - "x-accept-versioning": {"header": "X-Service-Version"}, - }, - }, - }, - } - FastApiSwaggerInstrument(SwaggerConfig(openapi_version_docs=build_version_docs_config())).bootstrap_after( - application - ) + def generator() -> dict[str, typing.Any]: + nonlocal calls + calls += 1 + return schema - with pytest.raises(ValueError, match="x-accept-versioning conflicts"): - application.openapi() + application.openapi = generator # type: ignore[method-assign] # Public custom OpenAPI hook. + FastApiSwaggerInstrument(SwaggerConfig(openapi_version_docs=version_docs())).bootstrap_after(application) + assert application.openapi() is schema + assert application.openapi() is schema + assert calls == EXPECTED_GENERATOR_CALLS + failing_application = fastapi.FastAPI() + failing_application.openapi = lambda: (_ for _ in ()).throw(RuntimeError("service-owned generator failed")) # type: ignore[method-assign] + FastApiSwaggerInstrument(SwaggerConfig(openapi_version_docs=version_docs())).bootstrap_after(failing_application) + with pytest.raises(RuntimeError, match="service-owned generator failed"): + failing_application.openapi() -def test_litestar_version_docs_preserve_service_owned_renderer_hooks_and_late_schema_cache() -> None: - startup_hook: typing.Final = MagicMock() - shutdown_hook: typing.Final = MagicMock() - application: typing.Final = build_litestar_application( - build_version_docs_config((OpenApiOperationSelector(path=TARGET_PATH, method="get"),)), - startup_hook, - shutdown_hook, - ) - assert isinstance(application.application, litestar.Litestar) - assert application.application.openapi_config is not None - assert application.application.openapi_config.render_plugins[0] is application.renderer - - expected_schema: typing.Final = application.schema() - with LitestarTestClient(app=application.application) as test_client: - first_served_schema: typing.Final = test_client.get(application.schema_path) - assert application.application.openapi_schema is not None - assert application.application.openapi_schema.paths is not None - target_path_item: typing.Final = application.application.openapi_schema.paths[TARGET_PATH] - assert isinstance(target_path_item, litestar_openapi.PathItem) - assert target_path_item.post is not None - target_path_item.post.description = LATE_OPERATION_DESCRIPTION - cached_schema: typing.Final = test_client.get(application.schema_path) - - assert first_served_schema.json() == expected_schema - assert cached_schema.json() == expected_schema - assert application.schema()["paths"][TARGET_PATH]["post"]["description"] == LATE_OPERATION_DESCRIPTION - startup_hook.assert_called_once_with(application.application) - shutdown_hook.assert_called_once_with(application.application) - - -def test_litestar_version_docs_preserve_standard_operation_fields() -> None: - expected_extension: typing.Final[dict[str, str | list[str]]] = { - "header": "Accept", - "mediaType": "application/vnd.real-api+json", - "parameter": "version", - "supportedVersions": ["2026-01", "release-candidate"], - } - application: typing.Final = build_litestar_application(None).application - assert isinstance(application, litestar.Litestar) - assert application.openapi_schema is not None - assert application.openapi_schema.paths is not None - path_item: typing.Final = application.openapi_schema.paths[TARGET_PATH] - assert isinstance(path_item, litestar_openapi.PathItem) - assert path_item.get is not None - standard_operation: typing.Final = path_item.get - standard_fields: typing.Final = { - field.name: getattr(standard_operation, field.name) +def test_litestar_preserves_renderer_hooks_and_served_schema_cache() -> None: + startup_hook = MagicMock() + shutdown_hook = MagicMock() + built = build_application("litestar", version_docs(), startup_hook=startup_hook, shutdown_hook=shutdown_hook) + assert isinstance(built.application, litestar.Litestar) + assert built.application.openapi_config is not None + assert built.application.openapi_config.render_plugins[0] is built.renderer + + expected_schema = built.schema() + with LitestarTestClient(app=built.application) as client: + assert client.get("/schema/openapi.json").json() == expected_schema + assert built.application.openapi_schema is not None + assert built.application.openapi_schema.paths is not None + built.application.openapi_schema.paths[TARGET_PATH].post.description = "Changed after serving" # type: ignore[union-attr] + assert client.get("/schema/openapi.json").json() == expected_schema + assert built.schema()["paths"][TARGET_PATH]["post"]["description"] == "Changed after serving" + startup_hook.assert_called_once_with(built.application) + shutdown_hook.assert_called_once_with(built.application) + + +def test_litestar_standard_and_custom_operations_preserve_fields_and_state() -> None: + built = build_application("litestar", None) + assert isinstance(built.application, litestar.Litestar) + assert built.application.openapi_schema is not None + assert built.application.openapi_schema.paths is not None + path_item = built.application.openapi_schema.paths[TARGET_PATH] + standard_operation = path_item.get + assert standard_operation is not None + standard_post = path_item.post + assert standard_post is not None + standard_fields = { + field.name: getattr(standard_post, field.name) for field in dataclasses.fields(litestar_openapi.Operation) if field.name != "description" } - - instrument: typing.Final = LitestarSwaggerInstrument( - SwaggerConfig(openapi_version_docs=build_version_docs_config()) - ) - instrument.bootstrap_after(application) - - documented_operation: typing.Final = path_item.get - assert documented_operation is not standard_operation - assert all(getattr(documented_operation, name) == value for name, value in standard_fields.items()) - assert documented_operation.to_schema()["x-accept-versioning"] == expected_extension - - instrument.bootstrap_after(application) - assert path_item.get is documented_operation - - canonical_schema: typing.Final = application.openapi_schema.to_schema() - with LitestarTestClient(app=application) as test_client: - served_schema: typing.Final = test_client.get("/schema/openapi.json") - - assert canonical_schema["paths"][TARGET_PATH]["get"]["x-accept-versioning"] == expected_extension - assert served_schema.status_code == status_codes.HTTP_200_OK - assert served_schema.json()["paths"][TARGET_PATH]["get"]["x-accept-versioning"] == expected_extension - - -def test_litestar_version_docs_copy_custom_aliased_operation() -> None: - expected_extension: typing.Final[dict[str, str | list[str]]] = { - "header": "Accept", - "mediaType": "application/vnd.real-api+json", - "parameter": "version", - "supportedVersions": ["2026-01", "release-candidate"], + original_fields = { + field.name: getattr(standard_operation, field.name) for field in dataclasses.fields(standard_operation) } @dataclasses.dataclass - class ServiceVersionedOperation(litestar_openapi.Operation): + class ServiceOperation(litestar_openapi.Operation): service_metadata: dict[str, str] | None = dataclasses.field( default=None, metadata={"alias": "x-service-metadata"}, @@ -798,146 +643,182 @@ class ServiceVersionedOperation(litestar_openapi.Operation): default=None, metadata={"alias": "x-accept-versioning"}, ) - rendering_state: str = dataclasses.field(init=False, default="draft") + state: str = dataclasses.field(init=False, default="draft") + + custom_operation = ServiceOperation(**original_fields, service_metadata={"owner": "widgets"}) + custom_operation.state = "published" + path_item.get = custom_operation + instrument = LitestarSwaggerInstrument(SwaggerConfig(openapi_version_docs=version_docs())) + instrument.bootstrap_after(built.application) + + documented = path_item.get + assert documented is not standard_operation + assert type(documented) is ServiceOperation + assert documented.service_metadata == {"owner": "widgets"} + assert documented.state == "published" + assert documented.accept_versioning == EXTENSION + assert all(getattr(path_item.post, name) == value for name, value in standard_fields.items()) + assert type(path_item.post).__name__ == "AcceptVersionedOperation" + assert built.schema()["paths"][TARGET_PATH]["get"]["x-accept-versioning"] == EXTENSION + assert built.served_schema()["paths"][TARGET_PATH]["get"]["x-accept-versioning"] == EXTENSION + documented_post = path_item.post + instrument.bootstrap_after(built.application) + assert path_item.get is documented + assert path_item.post is documented_post - def to_schema(self) -> dict[str, typing.Any]: - return {**super().to_schema(), "x-rendering-state": self.rendering_state} - application: typing.Final = build_litestar_application(None).application - assert isinstance(application, litestar.Litestar) - assert application.openapi_schema is not None - assert application.openapi_schema.paths is not None - path_item: typing.Final = application.openapi_schema.paths[TARGET_PATH] - assert isinstance(path_item, litestar_openapi.PathItem) +@pytest.mark.parametrize( + ("failure", "error"), + [ + ("conflicting_extension", "x-accept-versioning conflicts"), + ("non_string_description", "non-string description"), + ], +) +def test_litestar_aliased_operation_preflight_is_atomic_and_retryable(failure: str, error: str) -> None: + built = build_application("litestar", None) + assert isinstance(built.application, litestar.Litestar) + assert built.application.openapi_schema is not None + assert built.application.openapi_schema.paths is not None + path_item = built.application.openapi_schema.paths[TARGET_PATH] assert path_item.get is not None - operation_fields: typing.Final = { - field.name: getattr(path_item.get, field.name) for field in dataclasses.fields(litestar_openapi.Operation) - } - service_owned_operation: typing.Final = ServiceVersionedOperation( - **operation_fields, - service_metadata={"owner": "widgets"}, - ) - service_owned_operation.rendering_state = "published" - path_item.get = service_owned_operation - - instrument: typing.Final = LitestarSwaggerInstrument( - SwaggerConfig(openapi_version_docs=build_version_docs_config()) - ) - instrument.bootstrap_after(application) - - documented_operation: typing.Final = path_item.get - assert type(documented_operation) is ServiceVersionedOperation - assert documented_operation.service_metadata == {"owner": "widgets"} - assert documented_operation.rendering_state == "published" - assert documented_operation.to_schema()["x-accept-versioning"] == expected_extension - - assert ( - instrument._add_accept_versioning_extension(documented_operation, expected_extension) # noqa: SLF001 - is documented_operation - ) - conflicting_operation: typing.Final = copy.copy(documented_operation) - conflicting_operation.accept_versioning = {**expected_extension, "header": "X-Service-Version"} - with pytest.raises(ValueError, match="x-accept-versioning conflicts"): - instrument._add_accept_versioning_extension(conflicting_operation, expected_extension) # noqa: SLF001 - + assert path_item.post is not None -def test_litestar_version_docs_reject_unsupported_custom_operation_atomically() -> None: @dataclasses.dataclass - class ServiceMetadataOperation(litestar_openapi.Operation): - service_metadata: dict[str, str] | None = dataclasses.field( + class AliasedOperation(litestar_openapi.Operation): + accept_versioning: dict[str, str | list[str]] | None = dataclasses.field( default=None, - metadata={"alias": "x-service-metadata"}, + metadata={"alias": "x-accept-versioning"}, ) - application: typing.Final = build_litestar_application(None).application - assert isinstance(application, litestar.Litestar) - assert application.openapi_schema is not None - assert application.openapi_schema.paths is not None - path_item: typing.Final = application.openapi_schema.paths[TARGET_PATH] - assert isinstance(path_item, litestar_openapi.PathItem) - assert path_item.post is not None - original_get: typing.Final = path_item.get - operation_fields: typing.Final = { - field.name: getattr(path_item.post, field.name) for field in dataclasses.fields(litestar_openapi.Operation) - } - unsupported_operation: typing.Final = ServiceMetadataOperation( - **operation_fields, - service_metadata={"owner": "widgets"}, - ) - path_item.post = unsupported_operation - baseline_schema: typing.Final = copy.deepcopy(application.openapi_schema.to_schema()) - - instrument: typing.Final = LitestarSwaggerInstrument( + fields = {field.name: getattr(path_item.post, field.name) for field in dataclasses.fields(path_item.post)} + custom_operation = AliasedOperation(**fields) + if failure == "conflicting_extension": + custom_operation.accept_versioning = {"header": "X-Service-Version"} + else: + custom_operation.description = 1 # type: ignore[assignment] # Deliberately invalid service-owned schema. + path_item.post = custom_operation + original_get = path_item.get + baseline = copy.deepcopy(built.application.openapi_schema.to_schema()) + instrument = LitestarSwaggerInstrument( SwaggerConfig( security_schemes={"serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer")}, - openapi_version_docs=build_version_docs_config(), + openapi_version_docs=version_docs(), ) ) - with pytest.raises(TypeError, match="must declare an x-accept-versioning alias"): - instrument.bootstrap_after(application) - assert application.openapi_schema.to_schema() == baseline_schema + with pytest.raises(ValueError, match=error): + instrument.bootstrap_after(built.application) + + assert built.application.openapi_schema.to_schema() == baseline assert path_item.get is original_get - assert path_item.post is unsupported_operation - assert application.openapi_schema.components.security_schemes is not None - assert "serviceAuth" not in application.openapi_schema.components.security_schemes + assert path_item.post is custom_operation + assert built.application.openapi_schema.components.security_schemes is not None + assert "serviceAuth" not in built.application.openapi_schema.components.security_schemes + if failure == "conflicting_extension": + custom_operation.accept_versioning = None + else: + custom_operation.description = "Create widget" + instrument.bootstrap_after(built.application) + documented_post = path_item.post + instrument.bootstrap_after(built.application) -def test_fastapi_production_version_docs_preserve_custom_generator_and_errors() -> None: - application: typing.Final = fastapi.FastAPI() - invocation_count = 0 - service_schema: typing.Final = { - "openapi": "3.1.0", - "info": {"title": "Service API", "version": "1.0.0"}, - "paths": { - TARGET_PATH: { - "get": { - "description": GET_DESCRIPTION, - "responses": {"200": {"description": "OK"}}, - "security": [{"ServiceAuth": []}], - } - } - }, - "components": { - "schemas": {"ServiceOwned": {"type": "object"}}, - "securitySchemes": { - "GlobalAuth": {"type": "http", "scheme": "bearer"}, - "ServiceAuth": {"type": "http", "scheme": "bearer"}, - }, - }, - "security": [{"GlobalAuth": []}], + assert path_item.post is documented_post + assert built.application.openapi_schema.to_schema()["components"]["securitySchemes"]["serviceAuth"] == { + "type": "http", + "scheme": "bearer", } - expected_schema: typing.Final = build_expected_documented_schema(service_schema, build_version_docs_config()) + assert ( + built.application.openapi_schema.to_schema()["paths"][TARGET_PATH]["post"]["x-accept-versioning"] == EXTENSION + ) - def service_owned_openapi() -> dict[str, typing.Any]: - nonlocal invocation_count - invocation_count += 1 - return service_schema - application.openapi = service_owned_openapi # type: ignore[method-assign] # FastAPI's public custom OpenAPI hook. - FastApiSwaggerInstrument(SwaggerConfig(openapi_version_docs=build_version_docs_config())).bootstrap_after( - application - ) +def test_litestar_rejects_unsupported_custom_operation_without_partial_updates() -> None: + built = build_application("litestar", None) + assert isinstance(built.application, litestar.Litestar) + assert built.application.openapi_schema is not None + assert built.application.openapi_schema.paths is not None + path_item = built.application.openapi_schema.paths[TARGET_PATH] + assert path_item.post is not None - first_schema: typing.Final = application.openapi() - second_schema: typing.Final = application.openapi() + @dataclasses.dataclass + class UnsupportedOperation(litestar_openapi.Operation): + metadata: dict[str, str] | None = dataclasses.field(default=None, metadata={"alias": "x-service-metadata"}) - assert invocation_count == EXPECTED_GENERATOR_CALLS - assert first_schema is service_schema - assert second_schema is service_schema - assert first_schema == expected_schema - assert first_schema["components"] == expected_schema["components"] - assert first_schema["security"] == expected_schema["security"] - assert first_schema["paths"][TARGET_PATH]["get"]["security"] == [{"ServiceAuth": []}] + fields = {field.name: getattr(path_item.post, field.name) for field in dataclasses.fields(path_item.post)} + path_item.post = UnsupportedOperation(**fields, metadata={"owner": "widgets"}) + baseline = copy.deepcopy(built.schema()) + with pytest.raises(TypeError, match="must declare an x-accept-versioning alias"): + LitestarSwaggerInstrument( + SwaggerConfig( + security_schemes={"serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer")}, + openapi_version_docs=version_docs(), + ) + ).bootstrap_after(built.application) + assert built.schema() == baseline + assert built.application.openapi_schema.components.security_schemes is not None + assert "serviceAuth" not in built.application.openapi_schema.components.security_schemes + path_item.post = path_item.get + LitestarSwaggerInstrument( + SwaggerConfig( + security_schemes={"serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer")}, + openapi_version_docs=version_docs(), + ) + ).bootstrap_after(built.application) + assert "serviceAuth" in built.application.openapi_schema.components.security_schemes - def failing_service_owned_openapi() -> dict[str, typing.Any]: - raise RuntimeError("service-owned generator failed") - failing_application: typing.Final = fastapi.FastAPI() - failing_application.openapi = failing_service_owned_openapi # type: ignore[method-assign] # FastAPI's public hook. - FastApiSwaggerInstrument(SwaggerConfig(openapi_version_docs=build_version_docs_config())).bootstrap_after( - failing_application +@pytest.mark.parametrize("settings_type", [FastApiSettings, LitestarSettings]) +def test_settings_validate_security_schemes_and_operation_versions( + settings_type: type[FastApiSettings] | type[LitestarSettings], +) -> None: + settings = settings_type( + security_schemes={"serviceBearer": {"type": "http", "scheme": "bearer"}}, + openapi_version_docs={ + "vendor_media_type": "application/vnd.real-api+json", + "supported_versions": ("2026-01",), + "operation_versions": ({"path": TARGET_PATH, "method": "post", "supported_versions": ("2027-01",)},), + }, ) + assert settings.security_schemes == {"serviceBearer": OpenApiHttpSecurityScheme(scheme="bearer")} + assert settings.openapi_version_docs is not None + assert settings.openapi_version_docs.operation_versions[0].supported_versions == ("2027-01",) - with pytest.raises(RuntimeError, match="service-owned generator failed"): - failing_application.openapi() + +@pytest.mark.parametrize( + ("configuration", "message"), + [ + ( + lambda: OpenApiVersionDocsConfig( + vendor_media_type="application/vnd.real-api+json", + supported_versions=(), + ), + "requires at least one supported API version", + ), + ( + lambda: OpenApiVersionDocsConfig( + vendor_media_type="application/vnd.real-api+json", + supported_versions=("2026-01",), + operation_versions=( + OpenApiOperationVersionOverride( + path=TARGET_PATH, + method="post", + supported_versions=("2027-01",), + ), + OpenApiOperationVersionOverride( + path=TARGET_PATH, + method="post", + supported_versions=("2028-01",), + ), + ), + ), + "duplicate path and method pairs", + ), + ], +) +def test_version_docs_settings_reject_empty_global_versions_and_duplicate_override_selectors( + configuration: typing.Callable[[], object], + message: str, +) -> None: + with pytest.raises(ValueError, match=message): + configuration() diff --git a/tests/instruments/test_swagger.py b/tests/instruments/test_swagger.py index c9279cb..04b525e 100644 --- a/tests/instruments/test_swagger.py +++ b/tests/instruments/test_swagger.py @@ -13,7 +13,7 @@ from microbootstrap.bootstrappers.fastapi import FastApiSwaggerInstrument from microbootstrap.bootstrappers.litestar import LitestarSwaggerInstrument -from microbootstrap.instruments.openapi_version_docs import OpenApiOperationSelector, OpenApiVersionDocsConfig +from microbootstrap.instruments.openapi_version_docs import OpenApiOperationVersionOverride, OpenApiVersionDocsConfig from microbootstrap.instruments.swagger_instrument import SwaggerConfig, SwaggerInstrument @@ -184,20 +184,30 @@ def test_fastapi_swagger_bootstrap_working_offline_docs( @pytest.mark.parametrize( ("configuration", "error"), [ + ( + lambda: OpenApiVersionDocsConfig.model_validate({"vendor_media_type": "application/vnd.example+json"}), + "Field required", + ), + ( + lambda: OpenApiVersionDocsConfig.model_validate({"supported_versions": ("2026-01",)}), + "Field required", + ), ( lambda: OpenApiVersionDocsConfig( - enabled=True, - vendor_media_type="application/vnd.example+json", + vendor_media_type=None, + supported_versions=("2026-01",), ), - "requires at least one supported API version", + "Input should be a valid string", ), ( - lambda: OpenApiVersionDocsConfig(enabled=True, supported_versions=("2026-01",)), - "requires an explicit vendor media type", + lambda: OpenApiVersionDocsConfig( + vendor_media_type="application/vnd.example+json", + supported_versions=None, + ), + "Input should be a valid tuple", ), ( lambda: OpenApiVersionDocsConfig( - enabled=True, vendor_media_type="application/json", supported_versions=("1.0",), ), @@ -205,18 +215,17 @@ def test_fastapi_swagger_bootstrap_working_offline_docs( ), ( lambda: OpenApiVersionDocsConfig( - enabled=True, vendor_media_type="application/vnd.example+json", supported_versions=("1.0", "1.0"), ), "must not contain duplicates", ), ( - lambda: OpenApiOperationSelector(path="widgets", method="get"), + lambda: OpenApiOperationVersionOverride(path="widgets", method="get", supported_versions=()), "must be an absolute path", ), ( - lambda: OpenApiOperationSelector(path="/widgets", method="GET"), + lambda: OpenApiOperationVersionOverride(path="/widgets", method="GET", supported_versions=()), "Operation method must be one of", ), ], @@ -229,6 +238,29 @@ def test_openapi_version_docs_configuration_rejects_invalid_values( configuration() +@pytest.mark.parametrize("enabled", [False, True]) +def test_openapi_version_docs_rejects_removed_enabled_field(enabled: bool) -> None: + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + OpenApiVersionDocsConfig.model_validate( + {"enabled": enabled, "vendor_media_type": "application/vnd.example+json", "supported_versions": ("1.0",)} + ) + + +def test_openapi_version_docs_rejects_removed_suppression_and_unknown_override_fields() -> None: + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + OpenApiVersionDocsConfig.model_validate( + { + "vendor_media_type": "application/vnd.example+json", + "supported_versions": ("1.0",), + "suppressed_operations": (), + } + ) + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + OpenApiOperationVersionOverride.model_validate( + {"path": "/widgets", "method": "get", "supported_versions": (), "enabled": False} + ) + + @pytest.mark.parametrize( "vendor_media_type", [ @@ -249,7 +281,6 @@ def test_openapi_version_docs_configuration_rejects_invalid_values( def test_openapi_version_docs_rejects_unsafe_vendor_media_types(vendor_media_type: str) -> None: with pytest.raises(ValidationError, match="Vendor media type must use"): OpenApiVersionDocsConfig( - enabled=True, vendor_media_type=vendor_media_type, supported_versions=("release-2026",), ) @@ -272,7 +303,6 @@ def test_openapi_version_docs_rejects_unsafe_vendor_media_types(vendor_media_typ def test_openapi_version_docs_rejects_unsafe_versions(version: str) -> None: with pytest.raises(ValidationError, match="safe media-type token"): OpenApiVersionDocsConfig( - enabled=True, vendor_media_type="application/vnd.real-api+json", supported_versions=(version,), ) @@ -280,7 +310,6 @@ def test_openapi_version_docs_rejects_unsafe_versions(version: str) -> None: def test_openapi_version_docs_accepts_real_vendor_and_non_semver_versions() -> None: configuration: typing.Final = OpenApiVersionDocsConfig( - enabled=True, vendor_media_type="application/vnd.real-api_v2+json", supported_versions=("2026-01", "release-candidate", "v1.0+beta"), ) @@ -289,15 +318,11 @@ def test_openapi_version_docs_accepts_real_vendor_and_non_semver_versions() -> N assert configuration.supported_versions == ("2026-01", "release-candidate", "v1.0+beta") -def test_openapi_version_docs_disabled_default_is_usable() -> None: - configuration: typing.Final = OpenApiVersionDocsConfig() - - assert not configuration.enabled - assert configuration.vendor_media_type is None - assert not configuration.supported_versions - - -def test_openapi_version_docs_disabled_default_survives_model_dump_and_revalidation() -> None: - configuration: typing.Final = OpenApiVersionDocsConfig() +def test_openapi_version_docs_accepts_explicit_empty_operation_override_versions() -> None: + configuration: typing.Final = OpenApiOperationVersionOverride( + path="/widgets", + method="get", + supported_versions=(), + ) - assert OpenApiVersionDocsConfig.model_validate(configuration.model_dump()) == configuration + assert configuration.supported_versions == () From 847cbe8ec3becadf28b034c2428b29131289bd3a Mon Sep 17 00:00:00 2001 From: Mateo Li Date: Thu, 1 Oct 2026 11:47:15 +0300 Subject: [PATCH 05/10] Simplify OpenAPI augmentation and remove unrelated telemetry changes --- README.md | 22 +- microbootstrap/__init__.py | 2 - microbootstrap/bootstrappers/fastapi.py | 84 +----- microbootstrap/bootstrappers/litestar.py | 130 +++------ .../instruments/openapi_security_schemes.py | 4 +- .../instruments/swagger_instrument.py | 10 +- tests/bootstrappers/test_fastapi.py | 50 ---- tests/bootstrappers/test_faststream.py | 2 + .../test_litestar_opentelemetry.py | 77 +----- .../test_openapi_version_docs.py | 252 ++++-------------- tests/conftest.py | 74 +---- .../test_openapi_security_schemes.py | 111 +++++++- tests/instruments/test_opentelemetry.py | 67 +---- tests/instruments/test_pyroscope.py | 41 +-- 14 files changed, 248 insertions(+), 678 deletions(-) diff --git a/README.md b/README.md index c2a6cf1..0d40a4d 100644 --- a/README.md +++ b/README.md @@ -605,13 +605,12 @@ from microbootstrap import ( LitestarSettings, OpenApiHttpSecurityScheme, OpenApiOperationVersionOverride, - OpenApiSecurityScheme, OpenApiVersionDocsConfig, ) class Settings(LitestarSettings): - security_schemes: dict[str, OpenApiSecurityScheme] = { + security_schemes: dict[str, OpenApiHttpSecurityScheme] = { "serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer", bearer_format="JWT"), } openapi_version_docs: OpenApiVersionDocsConfig | None = OpenApiVersionDocsConfig( @@ -624,9 +623,11 @@ class Settings(LitestarSettings): ) ``` -HTTP, API key, OAuth 2.0, and OpenID Connect definitions are supported. Python field names and OpenAPI aliases are -accepted; output uses canonical names such as `bearerFormat`, `in`, `tokenUrl`, and `openIdConnectUrl`. A same-named -definition must be identical to the service-owned definition or schema generation raises `ValueError`. +HTTP, API key, OAuth 2.0, and OpenID Connect definitions are supported by `SwaggerConfig`. Annotating a consumer +setting with a concrete scheme class intentionally rejects other kinds for that consumer. Python field names and +OpenAPI aliases are accepted; output uses canonical names such as `bearerFormat`, `in`, `tokenUrl`, and +`openIdConnectUrl`. A same-named definition must be identical to the service-owned definition or schema generation +raises `ValueError`. Set `openapi_version_docs` to `None` to disable version documentation. A configured non-empty global `supported_versions` list adds an `x-accept-versioning` extension and matching description text to each operation. @@ -634,13 +635,10 @@ Set `openapi_version_docs` to `None` to disable version documentation. A configu microbootstrap's additions for that operation without asserting that no service-owned version metadata exists. This does not negotiate requests, add an `Accept` parameter, change response media types, or provide a Swagger UI version selector. -Breaking migration: replace `enabled=False` with `openapi_version_docs=None`, and replace each old suppressed operation -with an `OpenApiOperationVersionOverride` whose `supported_versions=()`. There is no compatibility shim. - -For Litestar, custom `Operation` subclasses must explicitly declare an `x-accept-versioning`-aliased dataclass field; -unsupported subclasses are rejected rather than losing custom state. For FastAPI, configure documentation before the -first OpenAPI request. The original `app.openapi` generator and its cache remain in use, so after correcting a -service-owned schema conflict, requesting the schema again applies the configured documentation to that cached schema. +The generated schema is the framework's normal schema with these definitions and operation fields added. Repeated +schema reads remain stable. A conflicting service-owned definition or `x-accept-versioning` extension raises +`ValueError`; correct the configuration and rebuild the application. Litestar supports its standard `Operation` type +for version documentation and rejects other custom operation subclasses. #### FastStream AsyncAPI documentation diff --git a/microbootstrap/__init__.py b/microbootstrap/__init__.py index c0884b5..51fe81e 100644 --- a/microbootstrap/__init__.py +++ b/microbootstrap/__init__.py @@ -8,7 +8,6 @@ OpenApiOAuthFlow, OpenApiOAuthFlows, OpenApiOpenIdConnectSecurityScheme, - OpenApiSecurityScheme, ) from microbootstrap.instruments.openapi_version_docs import ( OpenApiOperationVersionOverride, @@ -62,7 +61,6 @@ "OpenApiOAuthFlows", "OpenApiOpenIdConnectSecurityScheme", "OpenApiOperationVersionOverride", - "OpenApiSecurityScheme", "OpenApiVersionDocsConfig", "OpentelemetryConfig", "PyroscopeConfig", diff --git a/microbootstrap/bootstrappers/fastapi.py b/microbootstrap/bootstrappers/fastapi.py index 862702c..f0c247c 100644 --- a/microbootstrap/bootstrappers/fastapi.py +++ b/microbootstrap/bootstrappers/fastapi.py @@ -13,6 +13,7 @@ from microbootstrap.instruments.health_checks_instrument import HealthChecksInstrument, HealthCheckTypedDict from microbootstrap.instruments.logging_instrument import LoggingInstrument from microbootstrap.instruments.openapi_security_schemes import serialize_security_schemes +from microbootstrap.instruments.openapi_version_docs import SUPPORTED_HTTP_METHODS from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument from microbootstrap.instruments.prometheus_instrument import FastApiPrometheusConfig, PrometheusInstrument from microbootstrap.instruments.pyroscope_instrument import PyroscopeInstrument @@ -68,84 +69,37 @@ def bootstrap_before(self) -> dict[str, typing.Any]: def bootstrap_after(self, application: ApplicationT) -> ApplicationT: if self.instrument_config.swagger_offline_docs: enable_offline_docs(application, static_files_handler=self.instrument_config.service_static_path) - version_docs = self.instrument_config.openapi_version_docs - if version_docs is None and not self.instrument_config.security_schemes: + if self.instrument_config.openapi_version_docs is None and not self.instrument_config.security_schemes: return application original_openapi: typing.Final = application.openapi def documented_openapi() -> dict[str, typing.Any]: openapi_schema: typing.Final = original_openapi() - security_updates: dict[str, dict[str, typing.Any]] | None = None if self.instrument_config.security_schemes: - expected_schemes: typing.Final = serialize_security_schemes(self.instrument_config.security_schemes) - security_updates = self._prepare_security_scheme_updates(openapi_schema, expected_schemes) - version_documentation: typing.Final = self._prepare_version_documentation_updates(openapi_schema) - if security_updates is not None: - self._apply_security_scheme_updates(openapi_schema, security_updates) - self._apply_version_documentation_updates(version_documentation) + self._merge_security_schemes(openapi_schema) + if self.instrument_config.openapi_version_docs is not None: + self._document_operations(openapi_schema) return openapi_schema application.openapi = documented_openapi # type: ignore[method-assign] # FastAPI's public custom OpenAPI hook. return application - def _prepare_security_scheme_updates( - self, - openapi_schema: dict[str, typing.Any], - expected_schemes: dict[str, dict[str, typing.Any]], - ) -> dict[str, dict[str, typing.Any]]: - components = openapi_schema.get("components") - if components is None: - return expected_schemes - if not isinstance(components, dict): - message = "OpenAPI components must be a dictionary to configure security schemes." - raise TypeError(message) - security_schemes = components.get("securitySchemes") - if security_schemes is None: - return expected_schemes - if not isinstance(security_schemes, dict): - message = "OpenAPI components.securitySchemes must be a dictionary to configure security schemes." - raise TypeError(message) + def _merge_security_schemes(self, openapi_schema: dict[str, typing.Any]) -> None: + expected_schemes: typing.Final = serialize_security_schemes(self.instrument_config.security_schemes) + components = openapi_schema.setdefault("components", {}) + security_schemes = components.setdefault("securitySchemes", {}) self._validate_security_scheme_conflicts(security_schemes, expected_schemes) - return expected_schemes - - @staticmethod - def _apply_security_scheme_updates( - openapi_schema: dict[str, typing.Any], - expected_schemes: dict[str, dict[str, typing.Any]], - ) -> None: - components = openapi_schema.get("components") - if components is None: - openapi_schema["components"] = {"securitySchemes": expected_schemes} - return - assert isinstance(components, dict) # noqa: S101 - validated before application. - security_schemes = components.get("securitySchemes") - if security_schemes is None: - components["securitySchemes"] = expected_schemes - return - assert isinstance(security_schemes, dict) # noqa: S101 - validated before application. security_schemes.update( {name: scheme for name, scheme in expected_schemes.items() if name not in security_schemes} ) - def _prepare_version_documentation_updates( - self, - openapi_schema: dict[str, typing.Any], - ) -> list[tuple[dict[str, typing.Any], dict[str, str | list[str]], str]]: - updates: list[tuple[dict[str, typing.Any], dict[str, str | list[str]], str]] = [] - configuration = self.instrument_config.openapi_version_docs - if configuration is None: - return updates - paths = openapi_schema.get("paths") - if not isinstance(paths, dict): - return updates - for path, path_item in paths.items(): - if not isinstance(path, str) or not isinstance(path_item, dict): - continue + def _document_operations(self, openapi_schema: dict[str, typing.Any]) -> None: + for path, path_item in openapi_schema["paths"].items(): for method, operation in path_item.items(): - if not isinstance(method, str) or not isinstance(operation, dict): + if method not in SUPPORTED_HTTP_METHODS: continue - documentation = self._prepare_version_documentation( + documentation = self._build_version_documentation( path, method, operation.get("description"), @@ -153,17 +107,7 @@ def _prepare_version_documentation_updates( has_existing_extension="x-accept-versioning" in operation, ) if documentation is not None: - extension, description = documentation - updates.append((operation, extension, description)) - return updates - - @staticmethod - def _apply_version_documentation_updates( - updates: typing.Iterable[tuple[dict[str, typing.Any], dict[str, str | list[str]], str]], - ) -> None: - for operation, extension, description in updates: - operation["x-accept-versioning"] = extension - operation["description"] = description + operation["x-accept-versioning"], operation["description"] = documentation @FastApiBootstrapper.use_instrument() diff --git a/microbootstrap/bootstrappers/litestar.py b/microbootstrap/bootstrappers/litestar.py index a2397ca..8205ec7 100644 --- a/microbootstrap/bootstrappers/litestar.py +++ b/microbootstrap/bootstrappers/litestar.py @@ -1,5 +1,4 @@ from __future__ import annotations -import copy import dataclasses import typing @@ -34,7 +33,7 @@ OpenApiOAuthFlow, OpenApiOAuthFlows, OpenApiOpenIdConnectSecurityScheme, - OpenApiSecurityScheme, + _OpenApiSecurityScheme, serialize_security_schemes, ) from microbootstrap.instruments.openapi_version_docs import SUPPORTED_HTTP_METHODS @@ -127,24 +126,18 @@ def bootstrap_before(self) -> dict[str, typing.Any]: return bootstrap_result def bootstrap_after(self, application: ApplicationT) -> ApplicationT: - version_docs = self.instrument_config.openapi_version_docs - if (version_docs is None and not self.instrument_config.security_schemes) or application.openapi_schema is None: + if (self.instrument_config.openapi_version_docs is None and not self.instrument_config.security_schemes) or ( + application.openapi_schema is None + ): return application - security_updates: dict[str, openapi.spec.SecurityScheme] | None = None if self.instrument_config.security_schemes: - expected_schemes: typing.Final = serialize_security_schemes(self.instrument_config.security_schemes) - security_updates = self._prepare_security_scheme_updates(application.openapi_schema, expected_schemes) - version_documentation: typing.Final = self._prepare_version_documentation_updates(application.openapi_schema) - if security_updates is not None: - self._apply_security_scheme_updates(application.openapi_schema, security_updates) - self._apply_version_documentation_updates(version_documentation) + self._merge_security_schemes(application.openapi_schema) + if self.instrument_config.openapi_version_docs is not None: + self._document_operations(application.openapi_schema) return application - def _prepare_security_scheme_updates( - self, - openapi_schema: openapi.spec.OpenAPI, - expected_schemes: dict[str, dict[str, typing.Any]], - ) -> dict[str, openapi.spec.SecurityScheme]: + def _merge_security_schemes(self, openapi_schema: openapi.spec.OpenAPI) -> None: + expected_schemes: typing.Final = serialize_security_schemes(self.instrument_config.security_schemes) security_schemes = openapi_schema.components.security_schemes if security_schemes is not None: canonical_schemes: typing.Final = { @@ -152,54 +145,32 @@ def _prepare_security_scheme_updates( for name, scheme in security_schemes.items() } self._validate_security_scheme_conflicts(canonical_schemes, expected_schemes) - return { + configured_schemes = { scheme_name: self._build_litestar_security_scheme(security_scheme) for scheme_name, security_scheme in self.instrument_config.security_schemes.items() } - - @staticmethod - def _apply_security_scheme_updates( - openapi_schema: openapi.spec.OpenAPI, - expected_schemes: dict[str, openapi.spec.SecurityScheme], - ) -> None: - security_schemes = openapi_schema.components.security_schemes if security_schemes is None: openapi_schema.components.security_schemes = typing.cast( "dict[str, openapi.spec.SecurityScheme | openapi.spec.Reference]", - expected_schemes, + configured_schemes, ) return security_schemes.update( - {name: scheme for name, scheme in expected_schemes.items() if name not in security_schemes} + {name: scheme for name, scheme in configured_schemes.items() if name not in security_schemes} ) - def _prepare_version_documentation_updates( - self, - openapi_schema: openapi.spec.OpenAPI, - ) -> list[tuple[openapi.spec.PathItem, str, openapi.spec.Operation]]: - updates: list[tuple[openapi.spec.PathItem, str, openapi.spec.Operation]] = [] - configuration = self.instrument_config.openapi_version_docs - if configuration is None or openapi_schema.paths is None: - return updates + def _document_operations(self, openapi_schema: openapi.spec.OpenAPI) -> None: + if openapi_schema.paths is None: + return for path, path_item in openapi_schema.paths.items(): - if not isinstance(path_item, openapi.spec.PathItem): - continue for method in SUPPORTED_HTTP_METHODS: operation = getattr(path_item, method) if operation is None: continue - accept_versioning_field = next( - ( - field - for field in dataclasses.fields(operation) - if field.metadata.get("alias") == "x-accept-versioning" - ), - None, - ) existing_extension = ( - getattr(operation, accept_versioning_field.name) if accept_versioning_field is not None else None + operation.accept_versioning if isinstance(operation, AcceptVersionedOperation) else None ) - documentation = self._prepare_version_documentation( + documentation = self._build_version_documentation( path, method, operation.description, @@ -209,58 +180,25 @@ def _prepare_version_documentation_updates( if documentation is None: continue extension, description = documentation - documented_operation = self._prepare_documented_operation( - operation, - accept_versioning_field, - existing_extension, - extension, - description, - ) - updates.append((path_item, method, documented_operation)) - return updates - - @staticmethod - def _apply_version_documentation_updates( - updates: typing.Iterable[tuple[openapi.spec.PathItem, str, openapi.spec.Operation]], - ) -> None: - for path_item, method, operation in updates: - setattr(path_item, method, operation) - - @staticmethod - def _prepare_documented_operation( - operation: openapi.spec.Operation, - accept_versioning_field: dataclasses.Field[typing.Any] | None, - existing_extension: object, - expected_extension: dict[str, str | list[str]], - description: str, - ) -> openapi.spec.Operation: - if accept_versioning_field is not None: - if existing_extension is not None: - documented_operation = operation - else: - documented_operation = copy.copy(operation) - setattr(documented_operation, accept_versioning_field.name, expected_extension) - else: - if type(operation) is not openapi.spec.Operation: - message = ( - f"OpenAPI operation {type(operation).__name__} must declare an x-accept-versioning alias " - "to use Accept version documentation." - ) - raise TypeError(message) - init_fields = { - field.name: getattr(operation, field.name) - for field in dataclasses.fields(openapi.spec.Operation) - if field.init - } - documented_operation = AcceptVersionedOperation(**init_fields, accept_versioning=expected_extension) - if description != operation.description: - if documented_operation is operation: - documented_operation = copy.copy(documented_operation) - documented_operation.description = description - return documented_operation + if type(operation) is openapi.spec.Operation: + init_fields = { + field.name: getattr(operation, field.name) + for field in dataclasses.fields(openapi.spec.Operation) + if field.init + } + operation = AcceptVersionedOperation(**init_fields, accept_versioning=extension) + setattr(path_item, method, operation) + elif type(operation) is not AcceptVersionedOperation: + message = ( + f"OpenAPI operation {type(operation).__name__} is not supported " + "for Accept version documentation." + ) + raise TypeError(message) + operation.accept_versioning = extension + operation.description = description @classmethod - def _build_litestar_security_scheme(cls, security_scheme: OpenApiSecurityScheme) -> openapi.spec.SecurityScheme: + def _build_litestar_security_scheme(cls, security_scheme: _OpenApiSecurityScheme) -> openapi.spec.SecurityScheme: if isinstance(security_scheme, OpenApiHttpSecurityScheme): return openapi.spec.SecurityScheme( type=security_scheme.type, diff --git a/microbootstrap/instruments/openapi_security_schemes.py b/microbootstrap/instruments/openapi_security_schemes.py index 5820c9a..398703e 100644 --- a/microbootstrap/instruments/openapi_security_schemes.py +++ b/microbootstrap/instruments/openapi_security_schemes.py @@ -100,7 +100,7 @@ def validate_url(cls, value: str) -> str: return validated_value -OpenApiSecurityScheme: typing.TypeAlias = typing.Annotated[ +_OpenApiSecurityScheme: typing.TypeAlias = typing.Annotated[ OpenApiHttpSecurityScheme | OpenApiApiKeySecurityScheme | OpenApiOAuth2SecurityScheme @@ -119,7 +119,7 @@ def validate_openapi_url(value: str | None) -> str | None: def serialize_security_schemes( - security_schemes: typing.Mapping[str, OpenApiSecurityScheme], + security_schemes: typing.Mapping[str, _OpenApiSecurityScheme], ) -> dict[str, dict[str, typing.Any]]: return { scheme_name: security_scheme.model_dump(by_alias=True, exclude_none=True) diff --git a/microbootstrap/instruments/swagger_instrument.py b/microbootstrap/instruments/swagger_instrument.py index 3338d64..814b66e 100644 --- a/microbootstrap/instruments/swagger_instrument.py +++ b/microbootstrap/instruments/swagger_instrument.py @@ -6,7 +6,7 @@ from microbootstrap.helpers import is_valid_path from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument -from microbootstrap.instruments.openapi_security_schemes import OpenApiSecurityScheme # noqa: TC001 +from microbootstrap.instruments.openapi_security_schemes import _OpenApiSecurityScheme # noqa: TC001 from microbootstrap.instruments.openapi_version_docs import ( SUPPORTED_HTTP_METHODS, OpenApiVersionDocsConfig, @@ -25,15 +25,15 @@ class SwaggerConfig(BaseInstrumentConfig): swagger_path: str = "/docs" swagger_offline_docs: bool = False swagger_extra_params: dict[str, typing.Any] = pydantic.Field(default_factory=dict) - security_schemes: dict[str, OpenApiSecurityScheme] = pydantic.Field(default_factory=dict) + security_schemes: typing.Mapping[str, _OpenApiSecurityScheme] = pydantic.Field(default_factory=dict) openapi_version_docs: OpenApiVersionDocsConfig | None = None @pydantic.field_validator("security_schemes") @classmethod def validate_security_scheme_names( cls, - security_schemes: dict[str, OpenApiSecurityScheme], - ) -> dict[str, OpenApiSecurityScheme]: + security_schemes: typing.Mapping[str, _OpenApiSecurityScheme], + ) -> typing.Mapping[str, _OpenApiSecurityScheme]: for scheme_name in security_schemes: if SECURITY_SCHEME_NAME_PATTERN.fullmatch(scheme_name) is None: message = "OpenAPI security scheme names must match ^[a-zA-Z0-9._-]+$." @@ -48,7 +48,7 @@ class SwaggerInstrument(Instrument[SwaggerConfig]): def is_ready(self) -> bool: return bool(self.instrument_config.swagger_path) and is_valid_path(self.instrument_config.swagger_path) - def _prepare_version_documentation( + def _build_version_documentation( self, path: str, method: str, diff --git a/tests/bootstrappers/test_fastapi.py b/tests/bootstrappers/test_fastapi.py index 8c89a2a..902ea6e 100644 --- a/tests/bootstrappers/test_fastapi.py +++ b/tests/bootstrappers/test_fastapi.py @@ -3,7 +3,6 @@ from fastapi import status from fastapi.testclient import TestClient -from pydantic import BaseModel from microbootstrap.bootstrappers.fastapi import FastApiBootstrapper from microbootstrap.config.fastapi import FastApiConfig @@ -57,52 +56,3 @@ def test_fastapi_configure_application_lifespan(magic_mock: MagicMock) -> None: with TestClient(app=application): assert magic_mock.called - - -def test_fastapi_configure_application_openapi_and_documentation_options() -> None: - class Widget(BaseModel): - name: str - generated: str = "server-default" - - application: typing.Final = ( - FastApiBootstrapper(FastApiSettings(service_debug=False)) - .configure_application( - FastApiConfig( - title="Widgets API", - summary="Widget contract", - description="Configure widgets", - version="1.0.0", - openapi_url="/widget-schema.json", - docs_url="/widget-docs", - redoc_url="/widget-redoc", - separate_input_output_schemas=False, - ) - ) - .bootstrap() - ) - - @application.post("/widgets", response_model=Widget) - async def create_widget(widget: Widget) -> Widget: - return widget - - with TestClient(app=application) as test_client: - schema_response: typing.Final = test_client.get("/widget-schema.json") - swagger_response: typing.Final = test_client.get("/widget-docs") - redoc_response: typing.Final = test_client.get("/widget-redoc") - - assert schema_response.status_code == status.HTTP_200_OK - assert schema_response.json()["info"] == { - "title": "Widgets API", - "summary": "Widget contract", - "description": "Configure widgets", - "version": "1.0.0", - } - assert ( - schema_response.json()["components"]["schemas"]["Widget"]["properties"]["generated"]["default"] - == "server-default" - ) - assert application.separate_input_output_schemas is False - assert swagger_response.status_code == status.HTTP_200_OK - assert "/widget-schema.json" in swagger_response.text - assert redoc_response.status_code == status.HTTP_200_OK - assert "/widget-schema.json" in redoc_response.text diff --git a/tests/bootstrappers/test_faststream.py b/tests/bootstrappers/test_faststream.py index e11dee5..35f14fe 100644 --- a/tests/bootstrappers/test_faststream.py +++ b/tests/bootstrappers/test_faststream.py @@ -106,11 +106,13 @@ async def test_ok(self, broker: RedisBroker) -> None: @pytest.mark.parametrize("conversation_id", ["authoritative-value", None]) async def test_faststream_opentelemetry( + monkeypatch: pytest.MonkeyPatch, faker: faker.Faker, broker: RedisBroker, minimal_opentelemetry_config: OpentelemetryConfig, conversation_id: str | None, ) -> None: + monkeypatch.setattr("opentelemetry.sdk.trace.TracerProvider.shutdown", mock.Mock()) input_channel: typing.Final = faker.pystr() output_channel: typing.Final = faker.pystr() conversation_id_span_attribute: typing.Final = "conversation.id" diff --git a/tests/bootstrappers/test_litestar_opentelemetry.py b/tests/bootstrappers/test_litestar_opentelemetry.py index fa82217..71e1be6 100644 --- a/tests/bootstrappers/test_litestar_opentelemetry.py +++ b/tests/bootstrappers/test_litestar_opentelemetry.py @@ -4,10 +4,8 @@ import litestar import pytest from litestar.contrib.opentelemetry.config import OpenTelemetryConfig as LitestarOpentelemetryConfig -from litestar.status_codes import HTTP_200_OK, HTTP_201_CREATED +from litestar.status_codes import HTTP_200_OK from litestar.testing import TestClient -from opentelemetry.sdk.trace import ReadableSpan -from opentelemetry.trace import Span, SpanKind from microbootstrap import LitestarSettings from microbootstrap.bootstrappers.litestar import ( @@ -20,11 +18,6 @@ from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig -def _require_span_attributes(span: ReadableSpan) -> typing.Mapping[str, object]: - assert span.attributes is not None - return span.attributes - - @pytest.mark.parametrize( ("scope", "expected_span_name", "expected_attributes"), [ @@ -169,74 +162,6 @@ async def root() -> dict[str, str]: assert mock_function.call_args_list[0].args[0].get("path_template") == expected_path_template -def test_litestar_opentelemetry_body_post_hooks_export_completed_spans( - minimal_opentelemetry_config: OpentelemetryConfig, - in_memory_otel: typing.Any, # noqa: ANN401 -) -> None: - expected_request_count: typing.Final = 2 - server_hook_calls: list[tuple[Span, dict[str, typing.Any]]] = [] - client_hook_calls: list[tuple[Span, dict[str, typing.Any], dict[str, typing.Any]]] = [] - - def server_request_hook(span: Span, scope: dict[str, typing.Any]) -> None: - server_hook_calls.append((span, scope)) - span.set_attribute("test.server.scope_path", scope["path"]) - - def client_message_hook(span: Span, scope: dict[str, typing.Any], message: dict[str, typing.Any]) -> None: - client_hook_calls.append((span, scope, message)) - span.set_attribute("test.client.message_type", message["type"]) - - instrument = LitestarOpentelemetryInstrument(minimal_opentelemetry_config) - instrument.bootstrap() - bootstrap_result = instrument.bootstrap_before() - middleware = bootstrap_result["middleware"][0] - assert isinstance(middleware, LitestarOpenTelemetryInstrumentationMiddleware) - middleware.config.server_request_hook_handler = server_request_hook - middleware.config.client_request_hook_handler = client_message_hook - middleware.config.client_response_hook_handler = client_message_hook - - @litestar.post("/widgets/{widget_id:int}") - async def create_widget(widget_id: int, data: dict[str, str]) -> dict[str, str | int]: - return {"widget_id": widget_id, "name": data["name"]} - - application = litestar.Litestar(route_handlers=[create_widget], **bootstrap_result) - with TestClient(app=application) as client: - first_response = client.post("/widgets/41", json={"name": "first"}) - second_response = client.post("/widgets/42", json={"name": "second"}) - - assert first_response.status_code == second_response.status_code == HTTP_201_CREATED - assert [scope["path"] for _, scope in server_hook_calls] == ["/widgets/41", "/widgets/42"] - assert all(message["type"].startswith("http.") for _, _, message in client_hook_calls) - assert in_memory_otel.providers[-1].force_flush(timeout_millis=1_000) - - spans: list[ReadableSpan] = in_memory_otel.exporters[-1].get_finished_spans() - server_spans = [ - span - for span in spans - if span.kind == SpanKind.SERVER - and span.name == "POST /widgets/{widget_id}" - and _require_span_attributes(span)["http.route"] == "/widgets/{widget_id}" - ] - assert len(server_spans) == expected_request_count - assert all(_require_span_attributes(span)["http.status_code"] == HTTP_201_CREATED for span in server_spans) - assert {_require_span_attributes(span)["test.server.scope_path"] for span in server_spans} == { - "/widgets/41", - "/widgets/42", - } - assert {_require_span_attributes(span).get("test.client.message_type") for span in spans} >= { - "http.request", - "http.response.start", - } - assert all( - event.attributes is None or event.attributes.get("exception.type") != "TypeError" - for span in spans - for event in span.events - ) - - for server_span in server_spans: - assert server_span.parent is None - assert any(span.parent is not None and span.parent.span_id == server_span.context.span_id for span in spans) - - def test_litestar_opentelemetry_middleware_initialization() -> None: mock_app: typing.Final = Mock() diff --git a/tests/bootstrappers/test_openapi_version_docs.py b/tests/bootstrappers/test_openapi_version_docs.py index 0554e84..b290ecd 100644 --- a/tests/bootstrappers/test_openapi_version_docs.py +++ b/tests/bootstrappers/test_openapi_version_docs.py @@ -22,13 +22,13 @@ OpenApiOAuthFlows, OpenApiOpenIdConnectSecurityScheme, OpenApiOperationVersionOverride, - OpenApiSecurityScheme, OpenApiVersionDocsConfig, SwaggerConfig, ) from microbootstrap.bootstrappers.fastapi import FastApiBootstrapper, FastApiSwaggerInstrument from microbootstrap.bootstrappers.litestar import LitestarBootstrapper, LitestarSwaggerInstrument from microbootstrap.config.litestar import LitestarConfig +from microbootstrap.instruments.openapi_security_schemes import _OpenApiSecurityScheme from microbootstrap.settings import FastApiSettings, LitestarSettings @@ -114,7 +114,7 @@ def version_docs( def build_application( framework: str, config: OpenApiVersionDocsConfig | None, - security_schemes: dict[str, OpenApiSecurityScheme] | None = None, + security_schemes: dict[str, _OpenApiSecurityScheme] | None = None, startup_hook: MagicMock | None = None, shutdown_hook: MagicMock | None = None, ) -> BuiltApplication: @@ -356,7 +356,7 @@ def test_single_global_or_operation_override_version_and_accept_header_do_not_ch application.create_widget({"Accept": "application/vnd.real-api+json; version=2027-01"}) -def configured_schemes() -> dict[str, OpenApiSecurityScheme]: +def configured_schemes() -> dict[str, _OpenApiSecurityScheme]: return { "httpAuth": OpenApiHttpSecurityScheme(scheme="bearer", bearer_format="JWT"), "apiKeyAuth": OpenApiApiKeySecurityScheme(name="X-API-Key", location="header"), @@ -464,64 +464,7 @@ def custom_fastapi_application() -> tuple[fastapi.FastAPI, dict[str, typing.Any] return application, schema -@pytest.mark.parametrize( - ("mutation", "error"), - [ - (lambda operation: operation.update({"x-accept-versioning": {"header": "X-Service-Version"}}), "conflicts"), - (lambda operation: operation.update({"description": 1}), "non-string description"), - ], -) -def test_fastapi_preflight_is_atomic_and_retryable( - mutation: typing.Callable[[dict[str, typing.Any]], None], - error: str, -) -> None: - application, schema = custom_fastapi_application() - mutation(schema["paths"][TARGET_PATH]["post"]) - baseline = copy.deepcopy(schema) - instrument = FastApiSwaggerInstrument( - SwaggerConfig( - security_schemes={"serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer")}, - openapi_version_docs=version_docs(), - ) - ) - instrument.bootstrap_after(application) - - with pytest.raises(ValueError, match=error): - application.openapi() - assert schema == baseline - assert application.openapi_schema is None - - schema["paths"][TARGET_PATH]["post"] = {"description": "Create widget"} - assert application.openapi() is schema - assert schema["components"]["securitySchemes"]["serviceAuth"] == {"type": "http", "scheme": "bearer"} - assert schema["paths"][TARGET_PATH]["get"]["x-accept-versioning"] == EXTENSION - - -def test_fastapi_default_openapi_cache_retries_after_correcting_a_conflict() -> None: - application = fastapi.FastAPI() - - @application.get(TARGET_PATH, openapi_extra={"x-accept-versioning": {"header": "X-Service-Version"}}) - async def list_widgets() -> dict[str, str]: - return {"status": "ok"} - - FastApiSwaggerInstrument( - SwaggerConfig( - security_schemes={"serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer")}, - openapi_version_docs=version_docs(), - ) - ).bootstrap_after(application) - - with pytest.raises(ValueError, match="x-accept-versioning conflicts"): - application.openapi() - assert application.openapi_schema is not None - application.openapi_schema["paths"][TARGET_PATH]["get"].pop("x-accept-versioning") - - corrected_schema = application.openapi() - assert corrected_schema["components"]["securitySchemes"]["serviceAuth"] == {"type": "http", "scheme": "bearer"} - assert corrected_schema["paths"][TARGET_PATH]["get"]["x-accept-versioning"] == EXTENSION - - -def test_security_scheme_collisions_are_atomic_for_both_frameworks(framework: str) -> None: +def test_security_scheme_conflicts_leave_the_definition_batch_unchanged(framework: str) -> None: if framework == "fastapi": application, schema = custom_fastapi_application() schema["components"]["securitySchemes"] = { @@ -594,34 +537,66 @@ def generator() -> dict[str, typing.Any]: failing_application.openapi() -def test_litestar_preserves_renderer_hooks_and_served_schema_cache() -> None: - startup_hook = MagicMock() - shutdown_hook = MagicMock() - built = build_application("litestar", version_docs(), startup_hook=startup_hook, shutdown_hook=shutdown_hook) +def test_fastapi_documents_http_operations_without_altering_path_item_metadata() -> None: + application = fastapi.FastAPI() + schema: dict[str, typing.Any] = { + "openapi": "3.1.0", + "info": {"title": "Service API", "version": "1.0.0"}, + "paths": { + TARGET_PATH: { + "summary": "Widgets", + "parameters": [{"$ref": "#/components/parameters/RequestedBy"}], + "servers": [{"url": "https://widgets.example.test"}], + "x-service-metadata": {"owner": "widgets"}, + "get": {"description": "List widgets", "responses": {"200": {"description": "OK"}}}, + } + }, + "components": { + "parameters": {"RequestedBy": {"name": "X-Requested-By", "in": "header", "schema": {"type": "string"}}} + }, + } + application.openapi = lambda: schema # type: ignore[method-assign] # Public custom OpenAPI hook. + FastApiSwaggerInstrument(SwaggerConfig(openapi_version_docs=version_docs())).bootstrap_after(application) + + canonical_schema = application.openapi() + repeated_schema = application.openapi() + with FastAPITestClient(app=application) as client: + served_schema = client.get("/openapi.json") + + assert served_schema.status_code == status_codes.HTTP_200_OK + assert canonical_schema is schema + assert repeated_schema is schema + assert served_schema.json() == schema + assert schema["paths"][TARGET_PATH] == { + "summary": "Widgets", + "parameters": [{"$ref": "#/components/parameters/RequestedBy"}], + "servers": [{"url": "https://widgets.example.test"}], + "x-service-metadata": {"owner": "widgets"}, + "get": { + "description": f"List widgets\n\n{VERSION_TEXT}", + "responses": {"200": {"description": "OK"}}, + "x-accept-versioning": EXTENSION, + }, + } + + +def test_litestar_served_schema_matches_canonical_schema_on_repeated_reads() -> None: + built = build_application("litestar", version_docs()) assert isinstance(built.application, litestar.Litestar) assert built.application.openapi_config is not None assert built.application.openapi_config.render_plugins[0] is built.renderer expected_schema = built.schema() - with LitestarTestClient(app=built.application) as client: - assert client.get("/schema/openapi.json").json() == expected_schema - assert built.application.openapi_schema is not None - assert built.application.openapi_schema.paths is not None - built.application.openapi_schema.paths[TARGET_PATH].post.description = "Changed after serving" # type: ignore[union-attr] - assert client.get("/schema/openapi.json").json() == expected_schema - assert built.schema()["paths"][TARGET_PATH]["post"]["description"] == "Changed after serving" - startup_hook.assert_called_once_with(built.application) - shutdown_hook.assert_called_once_with(built.application) - - -def test_litestar_standard_and_custom_operations_preserve_fields_and_state() -> None: + assert built.served_schema() == expected_schema + assert built.served_schema() == expected_schema + + +def test_litestar_standard_operations_preserve_fields_and_are_stable() -> None: built = build_application("litestar", None) assert isinstance(built.application, litestar.Litestar) assert built.application.openapi_schema is not None assert built.application.openapi_schema.paths is not None path_item = built.application.openapi_schema.paths[TARGET_PATH] - standard_operation = path_item.get - assert standard_operation is not None standard_post = path_item.post assert standard_post is not None standard_fields = { @@ -629,111 +604,19 @@ def test_litestar_standard_and_custom_operations_preserve_fields_and_state() -> for field in dataclasses.fields(litestar_openapi.Operation) if field.name != "description" } - original_fields = { - field.name: getattr(standard_operation, field.name) for field in dataclasses.fields(standard_operation) - } - - @dataclasses.dataclass - class ServiceOperation(litestar_openapi.Operation): - service_metadata: dict[str, str] | None = dataclasses.field( - default=None, - metadata={"alias": "x-service-metadata"}, - ) - accept_versioning: dict[str, str | list[str]] | None = dataclasses.field( - default=None, - metadata={"alias": "x-accept-versioning"}, - ) - state: str = dataclasses.field(init=False, default="draft") - - custom_operation = ServiceOperation(**original_fields, service_metadata={"owner": "widgets"}) - custom_operation.state = "published" - path_item.get = custom_operation instrument = LitestarSwaggerInstrument(SwaggerConfig(openapi_version_docs=version_docs())) instrument.bootstrap_after(built.application) - documented = path_item.get - assert documented is not standard_operation - assert type(documented) is ServiceOperation - assert documented.service_metadata == {"owner": "widgets"} - assert documented.state == "published" - assert documented.accept_versioning == EXTENSION assert all(getattr(path_item.post, name) == value for name, value in standard_fields.items()) assert type(path_item.post).__name__ == "AcceptVersionedOperation" - assert built.schema()["paths"][TARGET_PATH]["get"]["x-accept-versioning"] == EXTENSION - assert built.served_schema()["paths"][TARGET_PATH]["get"]["x-accept-versioning"] == EXTENSION - documented_post = path_item.post - instrument.bootstrap_after(built.application) - assert path_item.get is documented - assert path_item.post is documented_post - - -@pytest.mark.parametrize( - ("failure", "error"), - [ - ("conflicting_extension", "x-accept-versioning conflicts"), - ("non_string_description", "non-string description"), - ], -) -def test_litestar_aliased_operation_preflight_is_atomic_and_retryable(failure: str, error: str) -> None: - built = build_application("litestar", None) - assert isinstance(built.application, litestar.Litestar) - assert built.application.openapi_schema is not None - assert built.application.openapi_schema.paths is not None - path_item = built.application.openapi_schema.paths[TARGET_PATH] - assert path_item.get is not None - assert path_item.post is not None - - @dataclasses.dataclass - class AliasedOperation(litestar_openapi.Operation): - accept_versioning: dict[str, str | list[str]] | None = dataclasses.field( - default=None, - metadata={"alias": "x-accept-versioning"}, - ) - - fields = {field.name: getattr(path_item.post, field.name) for field in dataclasses.fields(path_item.post)} - custom_operation = AliasedOperation(**fields) - if failure == "conflicting_extension": - custom_operation.accept_versioning = {"header": "X-Service-Version"} - else: - custom_operation.description = 1 # type: ignore[assignment] # Deliberately invalid service-owned schema. - path_item.post = custom_operation - original_get = path_item.get - baseline = copy.deepcopy(built.application.openapi_schema.to_schema()) - instrument = LitestarSwaggerInstrument( - SwaggerConfig( - security_schemes={"serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer")}, - openapi_version_docs=version_docs(), - ) - ) - - with pytest.raises(ValueError, match=error): - instrument.bootstrap_after(built.application) - - assert built.application.openapi_schema.to_schema() == baseline - assert path_item.get is original_get - assert path_item.post is custom_operation - assert built.application.openapi_schema.components.security_schemes is not None - assert "serviceAuth" not in built.application.openapi_schema.components.security_schemes - - if failure == "conflicting_extension": - custom_operation.accept_versioning = None - else: - custom_operation.description = "Create widget" - instrument.bootstrap_after(built.application) + assert built.schema()["paths"][TARGET_PATH]["post"]["x-accept-versioning"] == EXTENSION + assert built.served_schema()["paths"][TARGET_PATH]["post"]["x-accept-versioning"] == EXTENSION documented_post = path_item.post instrument.bootstrap_after(built.application) - assert path_item.post is documented_post - assert built.application.openapi_schema.to_schema()["components"]["securitySchemes"]["serviceAuth"] == { - "type": "http", - "scheme": "bearer", - } - assert ( - built.application.openapi_schema.to_schema()["paths"][TARGET_PATH]["post"]["x-accept-versioning"] == EXTENSION - ) -def test_litestar_rejects_unsupported_custom_operation_without_partial_updates() -> None: +def test_litestar_rejects_unsupported_custom_operation() -> None: built = build_application("litestar", None) assert isinstance(built.application, litestar.Litestar) assert built.application.openapi_schema is not None @@ -747,25 +630,8 @@ class UnsupportedOperation(litestar_openapi.Operation): fields = {field.name: getattr(path_item.post, field.name) for field in dataclasses.fields(path_item.post)} path_item.post = UnsupportedOperation(**fields, metadata={"owner": "widgets"}) - baseline = copy.deepcopy(built.schema()) - with pytest.raises(TypeError, match="must declare an x-accept-versioning alias"): - LitestarSwaggerInstrument( - SwaggerConfig( - security_schemes={"serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer")}, - openapi_version_docs=version_docs(), - ) - ).bootstrap_after(built.application) - assert built.schema() == baseline - assert built.application.openapi_schema.components.security_schemes is not None - assert "serviceAuth" not in built.application.openapi_schema.components.security_schemes - path_item.post = path_item.get - LitestarSwaggerInstrument( - SwaggerConfig( - security_schemes={"serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer")}, - openapi_version_docs=version_docs(), - ) - ).bootstrap_after(built.application) - assert "serviceAuth" in built.application.openapi_schema.components.security_schemes + with pytest.raises(TypeError, match="is not supported for Accept version documentation"): + LitestarSwaggerInstrument(SwaggerConfig(openapi_version_docs=version_docs())).bootstrap_after(built.application) @pytest.mark.parametrize("settings_type", [FastApiSettings, LitestarSettings]) diff --git a/tests/conftest.py b/tests/conftest.py index 18e1271..dc8aeca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,10 @@ from __future__ import annotations -import dataclasses import importlib import typing from unittest.mock import AsyncMock, MagicMock import litestar import pytest -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter from prometheus_client import REGISTRY from sentry_sdk.transport import Transport as SentryTransport @@ -30,56 +27,12 @@ if typing.TYPE_CHECKING: - from opentelemetry.sdk.resources import Resource from sentry_sdk.envelope import Envelope as SentryEnvelope pytestmark = [pytest.mark.anyio] -@dataclasses.dataclass -class InMemoryOpenTelemetry: - exporters: list[InMemorySpanExporter] = dataclasses.field(default_factory=list) - exporter_calls: list[tuple[tuple[object, ...], dict[str, object]]] = dataclasses.field(default_factory=list) - providers: list[TracerProvider] = dataclasses.field(default_factory=list) - - @staticmethod - def _flush_provider(provider: TracerProvider) -> None: - if not provider.force_flush(timeout_millis=1_000): - raise AssertionError("force_flush returned False") - - @staticmethod - def _record_cleanup_failure( - operation: str, - cleanup: typing.Callable[[], None], - failures: list[tuple[str, Exception]], - ) -> None: - try: - cleanup() - except Exception as exc: # noqa: BLE001 - fixture cleanup must continue for every owned provider. - failures.append((operation, exc)) - - def cleanup(self) -> None: - failures: list[tuple[str, Exception]] = [] - for provider_index, provider in enumerate(self.providers): - - def flush_provider(selected_provider: TracerProvider = provider) -> None: - self._flush_provider(selected_provider) - - self._record_cleanup_failure( - f"provider {provider_index} force_flush", - flush_provider, - failures, - ) - - for provider_index, provider in enumerate(self.providers): - self._record_cleanup_failure(f"provider {provider_index} shutdown", provider.shutdown, failures) - - if failures: - details = "; ".join(f"{operation}: {failure!r}" for operation, failure in failures) - raise RuntimeError(f"OpenTelemetry fixture cleanup failed: {details}") from failures[0][1] - - @pytest.fixture(scope="session", autouse=True) def anyio_backend() -> str: return "asyncio" @@ -186,32 +139,7 @@ def reset_reloaded_settings_module() -> typing.Iterator[None]: @pytest.fixture(autouse=True) def patch_out_entry_points(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(opentelemetry_instrument, "entry_points", MagicMock(return_value=[])) - - -@pytest.fixture(autouse=True) -def in_memory_otel(monkeypatch: pytest.MonkeyPatch) -> typing.Iterator[InMemoryOpenTelemetry]: - """Keep real SDK providers/processors while replacing OTLP delivery at its boundary.""" - harness = InMemoryOpenTelemetry() - - def create_exporter(*args: object, **kwargs: object) -> InMemorySpanExporter: - harness.exporter_calls.append((args, kwargs)) - exporter = InMemorySpanExporter() - harness.exporters.append(exporter) - return exporter - - def create_provider(*, resource: Resource | None = None) -> TracerProvider: - provider = TracerProvider(resource=resource) - harness.providers.append(provider) - return provider - - monkeypatch.setattr(opentelemetry_instrument, "OTLPSpanExporter", create_exporter) - monkeypatch.setattr(opentelemetry_instrument, "SdkTracerProvider", create_provider) - - try: - yield harness - finally: - harness.cleanup() + monkeypatch.setattr(opentelemetry_instrument, "entry_points", MagicMock(retrun_value=[])) @pytest.fixture(autouse=True) diff --git a/tests/instruments/test_openapi_security_schemes.py b/tests/instruments/test_openapi_security_schemes.py index a6cd387..4a62245 100644 --- a/tests/instruments/test_openapi_security_schemes.py +++ b/tests/instruments/test_openapi_security_schemes.py @@ -1,7 +1,7 @@ import typing import pytest -from pydantic import ValidationError +from pydantic import Field, ValidationError from microbootstrap import OpenApiApiKeySecurityScheme as ApiKeySecurityScheme from microbootstrap import ( @@ -10,14 +10,47 @@ OpenApiOAuthFlow, OpenApiOAuthFlows, OpenApiOpenIdConnectSecurityScheme, - OpenApiSecurityScheme, - SwaggerConfig, ) -from microbootstrap.instruments.openapi_security_schemes import serialize_security_schemes +from microbootstrap.instruments.instrument_box import InstrumentBox +from microbootstrap.instruments.openapi_security_schemes import _OpenApiSecurityScheme, serialize_security_schemes +from microbootstrap.instruments.swagger_instrument import SwaggerConfig, SwaggerInstrument +from microbootstrap.settings import LitestarSettings + + +class HttpOnlySettings(LitestarSettings): + security_schemes: dict[str, OpenApiHttpSecurityScheme] = Field(default_factory=dict) + + +class ApiKeyOnlySettings(LitestarSettings): + security_schemes: dict[str, ApiKeySecurityScheme] = Field(default_factory=dict) + + +class MixedSecuritySettings(LitestarSettings): + security_schemes: dict[str, OpenApiHttpSecurityScheme | ApiKeySecurityScheme] = Field(default_factory=dict) + + +@pytest.fixture +def http_only_settings() -> HttpOnlySettings: + return HttpOnlySettings(security_schemes={"http": OpenApiHttpSecurityScheme(scheme="bearer", bearer_format="JWT")}) + + +@pytest.fixture +def api_key_only_settings() -> ApiKeyOnlySettings: + return ApiKeyOnlySettings(security_schemes={"api": ApiKeySecurityScheme(name="X-API-Key", location="header")}) + + +@pytest.fixture +def mixed_security_settings() -> MixedSecuritySettings: + return MixedSecuritySettings( + security_schemes={ + "http": OpenApiHttpSecurityScheme(scheme="bearer"), + "api": ApiKeySecurityScheme(name="X-API-Key", location="query"), + } + ) def test_security_schemes_accept_python_names_and_serialize_openapi_aliases() -> None: - security_schemes: typing.Final[dict[str, OpenApiSecurityScheme]] = { + security_schemes: typing.Final[dict[str, _OpenApiSecurityScheme]] = { "http.auth": OpenApiHttpSecurityScheme(scheme="bearer", bearer_format="JWT"), "api-key": ApiKeySecurityScheme(name="X-API-Key", location="header"), "oauth2": OpenApiOAuth2SecurityScheme( @@ -56,6 +89,74 @@ def test_security_schemes_accept_python_names_and_serialize_openapi_aliases() -> } +def test_swagger_config_round_trips_all_security_scheme_types() -> None: + configuration: typing.Final = SwaggerConfig( + security_schemes={ + "http": OpenApiHttpSecurityScheme(scheme="bearer", bearer_format="JWT"), + "api": ApiKeySecurityScheme(name="X-API-Key", location="header"), + "oauth": OpenApiOAuth2SecurityScheme( + flows=OpenApiOAuthFlows(client_credentials=OpenApiOAuthFlow(token_url="/token")) # noqa: S106 + ), + "oidc": OpenApiOpenIdConnectSecurityScheme(open_id_connect_url="/.well-known/openid-configuration"), + } + ) + + from_dump: typing.Final = SwaggerConfig.model_validate(configuration.model_dump(by_alias=True)) + from_json: typing.Final = SwaggerConfig.model_validate_json(configuration.model_dump_json(by_alias=True)) + + for round_tripped in (from_dump, from_json): + assert type(round_tripped.security_schemes) is dict + assert round_tripped.security_schemes == configuration.security_schemes + assert isinstance(round_tripped.security_schemes["http"], OpenApiHttpSecurityScheme) + assert isinstance(round_tripped.security_schemes["api"], ApiKeySecurityScheme) + assert isinstance(round_tripped.security_schemes["oauth"], OpenApiOAuth2SecurityScheme) + assert isinstance(round_tripped.security_schemes["oidc"], OpenApiOpenIdConnectSecurityScheme) + + +def test_instrument_box_reconstructs_concrete_security_scheme_settings( + http_only_settings: HttpOnlySettings, + api_key_only_settings: ApiKeyOnlySettings, + mixed_security_settings: MixedSecuritySettings, +) -> None: + configurations: list[SwaggerConfig] = [] + for settings in (http_only_settings, api_key_only_settings, mixed_security_settings): + instrument_box = InstrumentBox(__instruments__=[SwaggerInstrument]) + instrument_box.initialize(settings) + + configuration = instrument_box.instruments[0].instrument_config + + assert isinstance(configuration, SwaggerConfig) + assert type(configuration.security_schemes) is dict + configurations.append(configuration) + + http_configuration, api_key_configuration, mixed_configuration = configurations + assert isinstance(http_configuration.security_schemes["http"], OpenApiHttpSecurityScheme) + assert isinstance(api_key_configuration.security_schemes["api"], ApiKeySecurityScheme) + assert isinstance(mixed_configuration.security_schemes["http"], OpenApiHttpSecurityScheme) + assert isinstance(mixed_configuration.security_schemes["api"], ApiKeySecurityScheme) + + +def test_instrument_box_merges_security_scheme_dicts() -> None: + instrument_box: typing.Final = InstrumentBox(__instruments__=[SwaggerInstrument]) + instrument_box.initialize(HttpOnlySettings(security_schemes={"http": OpenApiHttpSecurityScheme(scheme="bearer")})) + instrument_box.configure_instrument( + SwaggerConfig(security_schemes={"api": ApiKeySecurityScheme(name="X-API-Key", location="header")}) + ) + + configuration = instrument_box.instruments[0].instrument_config + + assert isinstance(configuration, SwaggerConfig) + assert type(configuration.security_schemes) is dict + assert set(configuration.security_schemes) == {"http", "api"} + assert isinstance(configuration.security_schemes["http"], OpenApiHttpSecurityScheme) + assert isinstance(configuration.security_schemes["api"], ApiKeySecurityScheme) + + +def test_http_only_settings_reject_other_security_scheme_kinds() -> None: + with pytest.raises(ValidationError): + HttpOnlySettings(security_schemes={"api": ApiKeySecurityScheme(name="X-API-Key", location="header")}) + + def test_security_schemes_accept_openapi_aliases() -> None: configuration: typing.Final = SwaggerConfig( security_schemes={ diff --git a/tests/instruments/test_opentelemetry.py b/tests/instruments/test_opentelemetry.py index 2870d6b..72dbe36 100644 --- a/tests/instruments/test_opentelemetry.py +++ b/tests/instruments/test_opentelemetry.py @@ -22,7 +22,6 @@ ) from microbootstrap.instruments import opentelemetry_instrument from microbootstrap.instruments.opentelemetry_instrument import BaggageSpanProcessor, OpentelemetryInstrument -from tests.conftest import InMemoryOpenTelemetry def test_opentelemetry_baggage_scope_overrides_removes_and_restores_values() -> None: @@ -179,66 +178,6 @@ def test_opentelemetry_bootstrap_registers_baggage_span_processor( } -def test_opentelemetry_bootstrap_exports_to_in_memory_delivery_boundary( - minimal_opentelemetry_config: OpentelemetryConfig, - in_memory_otel: typing.Any, # noqa: ANN401 -) -> None: - instrument = OpentelemetryInstrument(minimal_opentelemetry_config) - instrument.bootstrap() - - assert in_memory_otel.exporter_calls == [ - ( - (), - { - "endpoint": minimal_opentelemetry_config.opentelemetry_endpoint, - "insecure": minimal_opentelemetry_config.opentelemetry_insecure, - }, - ) - ] - - with instrument.tracer_provider.get_tracer(__name__).start_as_current_span("in-memory-export"): - pass - - assert in_memory_otel.providers[-1].force_flush(timeout_millis=1_000) - assert [span.name for span in in_memory_otel.exporters[-1].get_finished_spans()] == ["in-memory-export"] - - -def test_in_memory_opentelemetry_cleanup_flushes_and_shuts_down_all_owned_providers() -> None: - first_provider: typing.Final = MagicMock(spec=TracerProvider) - second_provider: typing.Final = MagicMock(spec=TracerProvider) - first_provider.force_flush.return_value = True - second_provider.force_flush.return_value = True - telemetry_harness = InMemoryOpenTelemetry(providers=[first_provider, second_provider]) - - telemetry_harness.cleanup() - - first_provider.force_flush.assert_called_once_with(timeout_millis=1_000) - second_provider.force_flush.assert_called_once_with(timeout_millis=1_000) - first_provider.shutdown.assert_called_once_with() - second_provider.shutdown.assert_called_once_with() - - -def test_in_memory_opentelemetry_cleanup_attempts_all_providers_and_surfaces_all_failures() -> None: - first_provider: typing.Final = MagicMock(spec=TracerProvider) - second_provider: typing.Final = MagicMock(spec=TracerProvider) - first_provider.force_flush.side_effect = RuntimeError("first flush failed") - second_provider.force_flush.return_value = False - first_provider.shutdown.side_effect = RuntimeError("first shutdown failed") - second_provider.shutdown.side_effect = RuntimeError("second shutdown failed") - telemetry_harness = InMemoryOpenTelemetry(providers=[first_provider, second_provider]) - - with pytest.raises( - RuntimeError, - match=r"first flush failed.*force_flush returned False.*first shutdown failed.*second shutdown failed", - ): - telemetry_harness.cleanup() - - first_provider.force_flush.assert_called_once_with(timeout_millis=1_000) - second_provider.force_flush.assert_called_once_with(timeout_millis=1_000) - first_provider.shutdown.assert_called_once_with() - second_provider.shutdown.assert_called_once_with() - - def test_opentelemetry_is_ready( minimal_opentelemetry_config: OpentelemetryConfig, ) -> None: @@ -323,7 +262,11 @@ async def test_handler() -> None: assert async_mock.called -def test_fastapi_opentelemetry_bootstrap_working(minimal_opentelemetry_config: OpentelemetryConfig) -> None: +def test_fastapi_opentelemetry_bootstrap_working( + minimal_opentelemetry_config: OpentelemetryConfig, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("opentelemetry.sdk.trace.TracerProvider.shutdown", Mock()) + test_opentelemetry_instrument: typing.Final = FastApiOpentelemetryInstrument(minimal_opentelemetry_config) test_opentelemetry_instrument.bootstrap() fastapi_application: typing.Final = test_opentelemetry_instrument.bootstrap_after(fastapi.FastAPI()) diff --git a/tests/instruments/test_pyroscope.py b/tests/instruments/test_pyroscope.py index cd7cce8..0c31b27 100644 --- a/tests/instruments/test_pyroscope.py +++ b/tests/instruments/test_pyroscope.py @@ -18,54 +18,31 @@ pytest.skip("pyroscope is not installed", allow_module_level=True) -@pytest.fixture -def pyroscope_library_boundary( - monkeypatch: pytest.MonkeyPatch, -) -> tuple[Mock, Mock, Mock, Mock]: - configure_mock = Mock() - shutdown_mock = Mock() - add_thread_tag_mock = Mock() - remove_thread_tag_mock = Mock() - monkeypatch.setattr(pyroscope, "configure", configure_mock) - monkeypatch.setattr(pyroscope, "shutdown", shutdown_mock) - monkeypatch.setattr(pyroscope, "add_thread_tag", add_thread_tag_mock) - monkeypatch.setattr(pyroscope, "remove_thread_tag", remove_thread_tag_mock) - return configure_mock, shutdown_mock, add_thread_tag_mock, remove_thread_tag_mock - - class TestPyroscopeInstrument: @pytest.fixture def minimal_pyroscope_config(self) -> PyroscopeConfig: return PyroscopeConfig(pyroscope_endpoint=pydantic.HttpUrl("http://localhost:4040")) - def test_ok( - self, - minimal_pyroscope_config: PyroscopeConfig, - pyroscope_library_boundary: tuple[Mock, Mock, Mock, Mock], - ) -> None: - configure_mock, shutdown_mock, _, _ = pyroscope_library_boundary + def test_ok(self, minimal_pyroscope_config: PyroscopeConfig) -> None: instrument = PyroscopeInstrument(minimal_pyroscope_config) assert instrument.is_ready() instrument.bootstrap() instrument.teardown() - configure_mock.assert_called_once_with( - application_name="micro-service", - server_address="http://localhost:4040/", - sample_rate=100, - tags={}, - ) - shutdown_mock.assert_called_once_with() def test_not_ready(self) -> None: instrument = PyroscopeInstrument(PyroscopeConfig(pyroscope_endpoint=None)) assert not instrument.is_ready() def test_opentelemetry_includes_pyroscope( - self, - minimal_opentelemetry_config: OpentelemetryConfig, - pyroscope_library_boundary: tuple[Mock, Mock, Mock, Mock], + self, monkeypatch: pytest.MonkeyPatch, minimal_opentelemetry_config: OpentelemetryConfig ) -> None: - _, _, add_thread_tag_mock, remove_thread_tag_mock = pyroscope_library_boundary + monkeypatch.setattr("opentelemetry.sdk.trace.TracerProvider.shutdown", Mock()) + monkeypatch.setattr( + "pyroscope.add_thread_tag", add_thread_tag_mock := Mock(side_effect=pyroscope.add_thread_tag) + ) + monkeypatch.setattr( + "pyroscope.remove_thread_tag", remove_thread_tag_mock := Mock(side_effect=pyroscope.remove_thread_tag) + ) minimal_opentelemetry_config.pyroscope_endpoint = pydantic.HttpUrl("http://localhost:4040") From 436a85055a902fb98a9e9b4c9323d87b53944cb5 Mon Sep 17 00:00:00 2001 From: Mateo Li Date: Thu, 1 Oct 2026 12:06:56 +0300 Subject: [PATCH 06/10] Align OpenAPI tests and separate documentation sections --- README.md | 40 +++++++---- .../test_openapi_version_docs.py | 67 +------------------ tests/instruments/test_swagger.py | 61 +++++++++++++++++ 3 files changed, 89 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 0d40a4d..f1524ac 100644 --- a/README.md +++ b/README.md @@ -594,9 +594,9 @@ Parameter descriptions: - `swagger_offline_docs` - A boolean value that, when set to True, allows the Swagger JS bundles to be accessed offline. This is because the service starts to host via static. - `swagger_extra_params` - Additional parameters to pass into the OpenAPI configuration. -#### Optional security schemes and API-version documentation +#### OpenAPI security schemes -Both additions are disabled by default. Security schemes add reusable OpenAPI definitions under +Security schemes are disabled by default. They add reusable OpenAPI definitions under `components.securitySchemes`; they do not authenticate requests or add global or operation-level security requirements. Keep requirements and authentication in your application routes and dependencies. @@ -604,8 +604,6 @@ Keep requirements and authentication in your application routes and dependencies from microbootstrap import ( LitestarSettings, OpenApiHttpSecurityScheme, - OpenApiOperationVersionOverride, - OpenApiVersionDocsConfig, ) @@ -613,6 +611,27 @@ class Settings(LitestarSettings): security_schemes: dict[str, OpenApiHttpSecurityScheme] = { "serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer", bearer_format="JWT"), } +``` + +HTTP, API key, OAuth 2.0, and OpenID Connect definitions are supported by `SwaggerConfig`. Annotating a consumer +setting with a concrete scheme class intentionally rejects other kinds for that consumer. Python field names and +OpenAPI aliases are accepted; output uses canonical names such as `bearerFormat`, `in`, `tokenUrl`, and +`openIdConnectUrl`. A same-named definition must be identical to the service-owned definition or schema generation +raises `ValueError`. + +The definitions are added to the framework's normal schema. Repeated schema reads remain stable; after correcting +a configuration conflict, rebuild the application. + +#### API-version documentation + +Version documentation is disabled by default. It describes supported versions in OpenAPI without implementing +runtime version negotiation. + +```python +from microbootstrap import LitestarSettings, OpenApiOperationVersionOverride, OpenApiVersionDocsConfig + + +class Settings(LitestarSettings): openapi_version_docs: OpenApiVersionDocsConfig | None = OpenApiVersionDocsConfig( vendor_media_type="application/vnd.example+json", supported_versions=("1.0",), @@ -623,22 +642,15 @@ class Settings(LitestarSettings): ) ``` -HTTP, API key, OAuth 2.0, and OpenID Connect definitions are supported by `SwaggerConfig`. Annotating a consumer -setting with a concrete scheme class intentionally rejects other kinds for that consumer. Python field names and -OpenAPI aliases are accepted; output uses canonical names such as `bearerFormat`, `in`, `tokenUrl`, and -`openIdConnectUrl`. A same-named definition must be identical to the service-owned definition or schema generation -raises `ValueError`. - Set `openapi_version_docs` to `None` to disable version documentation. A configured non-empty global `supported_versions` list adds an `x-accept-versioning` extension and matching description text to each operation. `operation_versions` replaces that list for one exact path and lower-case HTTP method; an explicit empty tuple skips microbootstrap's additions for that operation without asserting that no service-owned version metadata exists. This does not negotiate requests, add an `Accept` parameter, change response media types, or provide a Swagger UI version selector. -The generated schema is the framework's normal schema with these definitions and operation fields added. Repeated -schema reads remain stable. A conflicting service-owned definition or `x-accept-versioning` extension raises -`ValueError`; correct the configuration and rebuild the application. Litestar supports its standard `Operation` type -for version documentation and rejects other custom operation subclasses. +Repeated schema reads remain stable. A conflicting service-owned `x-accept-versioning` extension raises `ValueError`; +correct the configuration and rebuild the application. Litestar supports its standard `Operation` type for version +documentation and rejects other custom operation subclasses. #### FastStream AsyncAPI documentation diff --git a/tests/bootstrappers/test_openapi_version_docs.py b/tests/bootstrappers/test_openapi_version_docs.py index b290ecd..32b34f3 100644 --- a/tests/bootstrappers/test_openapi_version_docs.py +++ b/tests/bootstrappers/test_openapi_version_docs.py @@ -1,7 +1,6 @@ import copy import dataclasses import typing -from unittest.mock import MagicMock import fastapi import litestar @@ -61,7 +60,6 @@ class ServiceResponse(BaseModel): @dataclasses.dataclass class BuiltApplication: - framework: str application: fastapi.FastAPI | litestar.Litestar renderer: SwaggerRenderPlugin | None = None @@ -115,8 +113,6 @@ def build_application( framework: str, config: OpenApiVersionDocsConfig | None, security_schemes: dict[str, _OpenApiSecurityScheme] | None = None, - startup_hook: MagicMock | None = None, - shutdown_hook: MagicMock | None = None, ) -> BuiltApplication: if framework == "fastapi": fastapi_application = FastApiBootstrapper( @@ -144,7 +140,7 @@ async def fastapi_service_health() -> dict[str, str]: async def fastapi_without_description() -> dict[str, str]: return {"status": "ok"} - return BuiltApplication(framework, fastapi_application) + return BuiltApplication(fastapi_application) @get(TARGET_PATH, description="List widgets", security=[{"ServiceAuth": []}]) async def litestar_list_widgets() -> ServiceResponse: @@ -179,8 +175,6 @@ async def litestar_without_description() -> dict[str, str]: litestar_service_health, litestar_without_description, ], - on_startup=[startup_hook] if startup_hook is not None else [], - on_shutdown=[shutdown_hook] if shutdown_hook is not None else [], openapi_config=openapi.OpenAPIConfig( title="Service API", version="1.0.0", @@ -197,7 +191,7 @@ async def litestar_without_description() -> dict[str, str]: ) .bootstrap() ) - return BuiltApplication(framework, litestar_application, renderer) + return BuiltApplication(litestar_application, renderer) def test_version_docs_change_only_selected_operations_and_match_served_schema(framework: str) -> None: @@ -608,7 +602,6 @@ def test_litestar_standard_operations_preserve_fields_and_are_stable() -> None: instrument.bootstrap_after(built.application) assert all(getattr(path_item.post, name) == value for name, value in standard_fields.items()) - assert type(path_item.post).__name__ == "AcceptVersionedOperation" assert built.schema()["paths"][TARGET_PATH]["post"]["x-accept-versioning"] == EXTENSION assert built.served_schema()["paths"][TARGET_PATH]["post"]["x-accept-versioning"] == EXTENSION documented_post = path_item.post @@ -632,59 +625,3 @@ class UnsupportedOperation(litestar_openapi.Operation): path_item.post = UnsupportedOperation(**fields, metadata={"owner": "widgets"}) with pytest.raises(TypeError, match="is not supported for Accept version documentation"): LitestarSwaggerInstrument(SwaggerConfig(openapi_version_docs=version_docs())).bootstrap_after(built.application) - - -@pytest.mark.parametrize("settings_type", [FastApiSettings, LitestarSettings]) -def test_settings_validate_security_schemes_and_operation_versions( - settings_type: type[FastApiSettings] | type[LitestarSettings], -) -> None: - settings = settings_type( - security_schemes={"serviceBearer": {"type": "http", "scheme": "bearer"}}, - openapi_version_docs={ - "vendor_media_type": "application/vnd.real-api+json", - "supported_versions": ("2026-01",), - "operation_versions": ({"path": TARGET_PATH, "method": "post", "supported_versions": ("2027-01",)},), - }, - ) - assert settings.security_schemes == {"serviceBearer": OpenApiHttpSecurityScheme(scheme="bearer")} - assert settings.openapi_version_docs is not None - assert settings.openapi_version_docs.operation_versions[0].supported_versions == ("2027-01",) - - -@pytest.mark.parametrize( - ("configuration", "message"), - [ - ( - lambda: OpenApiVersionDocsConfig( - vendor_media_type="application/vnd.real-api+json", - supported_versions=(), - ), - "requires at least one supported API version", - ), - ( - lambda: OpenApiVersionDocsConfig( - vendor_media_type="application/vnd.real-api+json", - supported_versions=("2026-01",), - operation_versions=( - OpenApiOperationVersionOverride( - path=TARGET_PATH, - method="post", - supported_versions=("2027-01",), - ), - OpenApiOperationVersionOverride( - path=TARGET_PATH, - method="post", - supported_versions=("2028-01",), - ), - ), - ), - "duplicate path and method pairs", - ), - ], -) -def test_version_docs_settings_reject_empty_global_versions_and_duplicate_override_selectors( - configuration: typing.Callable[[], object], - message: str, -) -> None: - with pytest.raises(ValueError, match=message): - configuration() diff --git a/tests/instruments/test_swagger.py b/tests/instruments/test_swagger.py index 04b525e..ad79870 100644 --- a/tests/instruments/test_swagger.py +++ b/tests/instruments/test_swagger.py @@ -13,8 +13,13 @@ from microbootstrap.bootstrappers.fastapi import FastApiSwaggerInstrument from microbootstrap.bootstrappers.litestar import LitestarSwaggerInstrument +from microbootstrap.instruments.openapi_security_schemes import OpenApiHttpSecurityScheme from microbootstrap.instruments.openapi_version_docs import OpenApiOperationVersionOverride, OpenApiVersionDocsConfig from microbootstrap.instruments.swagger_instrument import SwaggerConfig, SwaggerInstrument +from microbootstrap.settings import FastApiSettings, LitestarSettings + + +TARGET_PATH: typing.Final = "/widgets" def test_swagger_is_ready(minimal_swagger_config: SwaggerConfig) -> None: @@ -326,3 +331,59 @@ def test_openapi_version_docs_accepts_explicit_empty_operation_override_versions ) assert configuration.supported_versions == () + + +@pytest.mark.parametrize("settings_type", [FastApiSettings, LitestarSettings]) +def test_settings_validate_security_schemes_and_operation_versions( + settings_type: type[FastApiSettings] | type[LitestarSettings], +) -> None: + settings = settings_type( + security_schemes={"serviceBearer": {"type": "http", "scheme": "bearer"}}, + openapi_version_docs={ + "vendor_media_type": "application/vnd.real-api+json", + "supported_versions": ("2026-01",), + "operation_versions": ({"path": TARGET_PATH, "method": "post", "supported_versions": ("2027-01",)},), + }, + ) + assert settings.security_schemes == {"serviceBearer": OpenApiHttpSecurityScheme(scheme="bearer")} + assert settings.openapi_version_docs is not None + assert settings.openapi_version_docs.operation_versions[0].supported_versions == ("2027-01",) + + +@pytest.mark.parametrize( + ("configuration", "message"), + [ + ( + lambda: OpenApiVersionDocsConfig( + vendor_media_type="application/vnd.real-api+json", + supported_versions=(), + ), + "requires at least one supported API version", + ), + ( + lambda: OpenApiVersionDocsConfig( + vendor_media_type="application/vnd.real-api+json", + supported_versions=("2026-01",), + operation_versions=( + OpenApiOperationVersionOverride( + path=TARGET_PATH, + method="post", + supported_versions=("2027-01",), + ), + OpenApiOperationVersionOverride( + path=TARGET_PATH, + method="post", + supported_versions=("2028-01",), + ), + ), + ), + "duplicate path and method pairs", + ), + ], +) +def test_version_docs_settings_reject_empty_global_versions_and_duplicate_override_selectors( + configuration: typing.Callable[[], object], + message: str, +) -> None: + with pytest.raises(ValueError, match=message): + configuration() From 8d991d205cb7ef17a6cfbe118e5801e353067b49 Mon Sep 17 00:00:00 2001 From: Mateo Li Date: Thu, 1 Oct 2026 13:40:44 +0300 Subject: [PATCH 07/10] Fix FastStream logger interception in Sentry tests --- tests/bootstrappers/test_faststream.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/bootstrappers/test_faststream.py b/tests/bootstrappers/test_faststream.py index 35f14fe..feddd24 100644 --- a/tests/bootstrappers/test_faststream.py +++ b/tests/bootstrappers/test_faststream.py @@ -193,17 +193,19 @@ async def handler(conversation_id: str) -> None: FastStreamConfig(broker=broker) ).configure_instruments(minimal_sentry_config).bootstrap() - original_log = broker.config.logger.log + logger_state: typing.Final = broker.config.logger + logger_type: typing.Final = type(logger_state) + original_log: typing.Final = logger_type.log - def record_error_tag(*args: typing.Any, **kwargs: typing.Any) -> None: # noqa: ANN401 - if kwargs.get("log_level") == logging.ERROR: + def record_error_tag(state: typing.Any, *args: typing.Any, **kwargs: typing.Any) -> None: # noqa: ANN401 + if state is logger_state and kwargs.get("log_level") == logging.ERROR: error_message = typing.cast("str", kwargs["message"]) captured_tags[error_message] = sentry_sdk.get_isolation_scope()._tags.get(conversation_id_tag) # noqa: SLF001 if error_message.endswith("second"): second_logged.set() - original_log(*args, **kwargs) + original_log(state, *args, **kwargs) - monkeypatch.setattr(broker.config.logger, "log", record_error_tag) + monkeypatch.setattr(logger_type, "log", record_error_tag) event_loop = asyncio.get_running_loop() previous_exception_handler = event_loop.get_exception_handler() event_loop.set_exception_handler(lambda *_: None) @@ -267,10 +269,12 @@ async def handler(conversation_id: str) -> None: client.get_integration.return_value = baggage_integration monkeypatch.setattr(sentry_sdk, "get_client", mock.Mock(return_value=client)) before_send: typing.Final = init.call_args.kwargs["before_send"] - original_log = broker.config.logger.log + logger_state: typing.Final = broker.config.logger + logger_type: typing.Final = type(logger_state) + original_log: typing.Final = logger_type.log - def record_automatic_error(*args: typing.Any, **kwargs: typing.Any) -> None: # noqa: ANN401 - if kwargs.get("log_level") == logging.ERROR: + def record_automatic_error(state: typing.Any, *args: typing.Any, **kwargs: typing.Any) -> None: # noqa: ANN401 + if state is logger_state and kwargs.get("log_level") == logging.ERROR: exception = typing.cast("ValueError", kwargs["exc_info"]) event = before_send( {}, @@ -279,9 +283,9 @@ def record_automatic_error(*args: typing.Any, **kwargs: typing.Any) -> None: # captured_tags[str(exception)] = event.get("tags", {}).get(conversation_id_tag) if str(exception) == "second": second_captured.set() - original_log(*args, **kwargs) + original_log(state, *args, **kwargs) - monkeypatch.setattr(broker.config.logger, "log", record_automatic_error) + monkeypatch.setattr(logger_type, "log", record_automatic_error) event_loop = asyncio.get_running_loop() previous_exception_handler = event_loop.get_exception_handler() From 52394e7bd094a3e4ecf2df38cbfc16c3ff26c04e Mon Sep 17 00:00:00 2001 From: Mateo Li Date: Mon, 5 Oct 2026 15:14:00 +0300 Subject: [PATCH 08/10] Simplify OpenAPI adapters and cover schema edge cases --- microbootstrap/bootstrappers/fastapi.py | 4 +- microbootstrap/bootstrappers/litestar.py | 71 +++----- .../instruments/swagger_instrument.py | 19 +- .../test_openapi_version_docs.py | 44 +++++ .../test_openapi_security_schemes.py | 37 ++-- tests/instruments/test_swagger.py | 167 +++++++++++++++++- 6 files changed, 248 insertions(+), 94 deletions(-) diff --git a/microbootstrap/bootstrappers/fastapi.py b/microbootstrap/bootstrappers/fastapi.py index f0c247c..07aab1b 100644 --- a/microbootstrap/bootstrappers/fastapi.py +++ b/microbootstrap/bootstrappers/fastapi.py @@ -95,7 +95,9 @@ def _merge_security_schemes(self, openapi_schema: dict[str, typing.Any]) -> None ) def _document_operations(self, openapi_schema: dict[str, typing.Any]) -> None: - for path, path_item in openapi_schema["paths"].items(): + for path, path_item in openapi_schema.get("paths", {}).items(): + if path.startswith("x-"): + continue for method, operation in path_item.items(): if method not in SUPPORTED_HTTP_METHODS: continue diff --git a/microbootstrap/bootstrappers/litestar.py b/microbootstrap/bootstrappers/litestar.py index 8205ec7..e0b8fdc 100644 --- a/microbootstrap/bootstrappers/litestar.py +++ b/microbootstrap/bootstrappers/litestar.py @@ -30,8 +30,6 @@ OpenApiApiKeySecurityScheme, OpenApiHttpSecurityScheme, OpenApiOAuth2SecurityScheme, - OpenApiOAuthFlow, - OpenApiOAuthFlows, OpenApiOpenIdConnectSecurityScheme, _OpenApiSecurityScheme, serialize_security_schemes, @@ -127,7 +125,7 @@ def bootstrap_before(self) -> dict[str, typing.Any]: def bootstrap_after(self, application: ApplicationT) -> ApplicationT: if (self.instrument_config.openapi_version_docs is None and not self.instrument_config.security_schemes) or ( - application.openapi_schema is None + application.openapi_config is None ): return application if self.instrument_config.security_schemes: @@ -199,54 +197,27 @@ def _document_operations(self, openapi_schema: openapi.spec.OpenAPI) -> None: @classmethod def _build_litestar_security_scheme(cls, security_scheme: _OpenApiSecurityScheme) -> openapi.spec.SecurityScheme: - if isinstance(security_scheme, OpenApiHttpSecurityScheme): - return openapi.spec.SecurityScheme( - type=security_scheme.type, - scheme=security_scheme.scheme, - bearer_format=security_scheme.bearer_format, - description=security_scheme.description, - ) - if isinstance(security_scheme, OpenApiApiKeySecurityScheme): - return openapi.spec.SecurityScheme( - type=security_scheme.type, - name=security_scheme.name, - security_scheme_in=security_scheme.location, - description=security_scheme.description, - ) - if isinstance(security_scheme, OpenApiOAuth2SecurityScheme): - return openapi.spec.SecurityScheme( - type=security_scheme.type, - flows=cls._build_litestar_oauth_flows(security_scheme.flows), - description=security_scheme.description, - ) - if isinstance(security_scheme, OpenApiOpenIdConnectSecurityScheme): - return openapi.spec.SecurityScheme( - type=security_scheme.type, - open_id_connect_url=security_scheme.open_id_connect_url, - description=security_scheme.description, + if not isinstance( + security_scheme, + ( + OpenApiHttpSecurityScheme, + OpenApiApiKeySecurityScheme, + OpenApiOAuth2SecurityScheme, + OpenApiOpenIdConnectSecurityScheme, + ), + ): + raise AssertionError("Unsupported OpenAPI security scheme.") # noqa: TRY004 + scheme_data = security_scheme.model_dump(by_alias=False, exclude_none=True) + if "location" in scheme_data: + scheme_data["security_scheme_in"] = scheme_data.pop("location") + if "flows" in scheme_data: + flow_data = scheme_data["flows"] + if "resource_owner" in flow_data: + flow_data["password"] = flow_data.pop("resource_owner") + scheme_data["flows"] = openapi.spec.OAuthFlows( + **{flow_name: openapi.spec.OAuthFlow(**flow) for flow_name, flow in flow_data.items()} ) - raise AssertionError("Unsupported OpenAPI security scheme.") - - @classmethod - def _build_litestar_oauth_flows(cls, oauth_flows: OpenApiOAuthFlows) -> openapi.spec.OAuthFlows: - flows = { - "implicit": cls._build_litestar_oauth_flow(oauth_flows.implicit), - "password": cls._build_litestar_oauth_flow(object.__getattribute__(oauth_flows, "resource_owner")), - "client_credentials": cls._build_litestar_oauth_flow(oauth_flows.client_credentials), - "authorization_code": cls._build_litestar_oauth_flow(oauth_flows.authorization_code), - } - return openapi.spec.OAuthFlows(**flows) - - @classmethod - def _build_litestar_oauth_flow(cls, oauth_flow: OpenApiOAuthFlow | None) -> openapi.spec.OAuthFlow | None: - if oauth_flow is None: - return None - return openapi.spec.OAuthFlow( - authorization_url=oauth_flow.authorization_url, - token_url=oauth_flow.token_url, - refresh_url=oauth_flow.refresh_url, - scopes=oauth_flow.scopes, - ) + return openapi.spec.SecurityScheme(**scheme_data) @LitestarBootstrapper.use_instrument() diff --git a/microbootstrap/instruments/swagger_instrument.py b/microbootstrap/instruments/swagger_instrument.py index 814b66e..00efbda 100644 --- a/microbootstrap/instruments/swagger_instrument.py +++ b/microbootstrap/instruments/swagger_instrument.py @@ -81,11 +81,9 @@ def _build_version_documentation( "parameter": "version", "supportedVersions": list(supported_versions), } - self._validate_accept_versioning_extension( - existing_extension, - expected_extension, - has_existing_extension=has_existing_extension, - ) + if has_existing_extension and existing_extension != expected_extension: + message = "OpenAPI operation x-accept-versioning conflicts with configured Accept version documentation." + raise ValueError(message) return expected_extension, self._format_version_documentation(description, configuration, supported_versions) @staticmethod @@ -108,17 +106,6 @@ def _format_version_documentation( return description return f"{description}\n\n{version_documentation}" - @staticmethod - def _validate_accept_versioning_extension( - existing_extension: object, - expected_extension: dict[str, str | list[str]], - *, - has_existing_extension: bool, - ) -> None: - if has_existing_extension and existing_extension != expected_extension: - message = "OpenAPI operation x-accept-versioning conflicts with configured Accept version documentation." - raise ValueError(message) - @staticmethod def _validate_security_scheme_conflicts( existing_schemes: typing.Mapping[str, object], diff --git a/tests/bootstrappers/test_openapi_version_docs.py b/tests/bootstrappers/test_openapi_version_docs.py index 32b34f3..cd1d2fa 100644 --- a/tests/bootstrappers/test_openapi_version_docs.py +++ b/tests/bootstrappers/test_openapi_version_docs.py @@ -8,6 +8,7 @@ from fastapi.security import HTTPBearer from fastapi.testclient import TestClient as FastAPITestClient from litestar import get, openapi, post, status_codes +from litestar.config.app import AppConfig from litestar.openapi import spec as litestar_openapi from litestar.openapi.plugins import SwaggerRenderPlugin from litestar.testing import TestClient as LitestarTestClient @@ -243,11 +244,13 @@ def test_empty_operation_override_skips_malformed_service_owned_metadata(framewo application, schema = custom_fastapi_application() operation = schema["paths"][TARGET_PATH]["get"] operation.update({"description": 1, "x-accept-versioning": {"header": "X-Service-Version"}}) + snapshot = copy.deepcopy(operation) FastApiSwaggerInstrument(SwaggerConfig(openapi_version_docs=configuration)).bootstrap_after(application) assert application.openapi() is schema assert schema["paths"][TARGET_PATH]["get"] is operation + assert operation == snapshot return built = build_application("litestar", None) @@ -268,10 +271,12 @@ class ServiceOperation(litestar_openapi.Operation): operation = ServiceOperation(**fields, accept_versioning={"header": "X-Service-Version"}) operation.description = 1 # type: ignore[assignment] # Deliberately invalid service-owned schema. path_item.get = operation + snapshot = copy.deepcopy(operation) LitestarSwaggerInstrument(SwaggerConfig(openapi_version_docs=configuration)).bootstrap_after(built.application) assert path_item.get is operation + assert operation == snapshot def test_version_docs_do_not_create_absent_security_scheme_containers(framework: str) -> None: @@ -585,6 +590,45 @@ def test_litestar_served_schema_matches_canonical_schema_on_repeated_reads() -> assert built.served_schema() == expected_schema +@pytest.mark.parametrize( + ("has_security_schemes", "has_version_docs"), + [(False, False), (True, False), (False, True), (True, True)], +) +def test_litestar_bootstraps_without_openapi_config( + has_security_schemes: bool, + has_version_docs: bool, +) -> None: + @get(TARGET_PATH) + async def handler() -> dict[str, str]: + return {"status": "ok"} + + def disable_openapi(configuration: AppConfig) -> AppConfig: + configuration.openapi_config = None + return configuration + + application = ( + LitestarBootstrapper( + LitestarSettings( + service_debug=False, + security_schemes={"ServiceAuth": OpenApiHttpSecurityScheme(scheme="bearer")} + if has_security_schemes + else {}, + openapi_version_docs=version_docs() if has_version_docs else None, + ) + ) + .configure_application(LitestarConfig(route_handlers=[handler], on_app_init=[disable_openapi])) + .bootstrap() + ) + + assert application.openapi_config is None + with LitestarTestClient(app=application) as client: + response = client.get(TARGET_PATH) + openapi_response = client.get("/docs/openapi.json") + assert response.status_code == status_codes.HTTP_200_OK + assert response.json() == {"status": "ok"} + assert openapi_response.status_code == status_codes.HTTP_404_NOT_FOUND + + def test_litestar_standard_operations_preserve_fields_and_are_stable() -> None: built = build_application("litestar", None) assert isinstance(built.application, litestar.Litestar) diff --git a/tests/instruments/test_openapi_security_schemes.py b/tests/instruments/test_openapi_security_schemes.py index 4a62245..46d557c 100644 --- a/tests/instruments/test_openapi_security_schemes.py +++ b/tests/instruments/test_openapi_security_schemes.py @@ -29,26 +29,6 @@ class MixedSecuritySettings(LitestarSettings): security_schemes: dict[str, OpenApiHttpSecurityScheme | ApiKeySecurityScheme] = Field(default_factory=dict) -@pytest.fixture -def http_only_settings() -> HttpOnlySettings: - return HttpOnlySettings(security_schemes={"http": OpenApiHttpSecurityScheme(scheme="bearer", bearer_format="JWT")}) - - -@pytest.fixture -def api_key_only_settings() -> ApiKeyOnlySettings: - return ApiKeyOnlySettings(security_schemes={"api": ApiKeySecurityScheme(name="X-API-Key", location="header")}) - - -@pytest.fixture -def mixed_security_settings() -> MixedSecuritySettings: - return MixedSecuritySettings( - security_schemes={ - "http": OpenApiHttpSecurityScheme(scheme="bearer"), - "api": ApiKeySecurityScheme(name="X-API-Key", location="query"), - } - ) - - def test_security_schemes_accept_python_names_and_serialize_openapi_aliases() -> None: security_schemes: typing.Final[dict[str, _OpenApiSecurityScheme]] = { "http.auth": OpenApiHttpSecurityScheme(scheme="bearer", bearer_format="JWT"), @@ -113,13 +93,18 @@ def test_swagger_config_round_trips_all_security_scheme_types() -> None: assert isinstance(round_tripped.security_schemes["oidc"], OpenApiOpenIdConnectSecurityScheme) -def test_instrument_box_reconstructs_concrete_security_scheme_settings( - http_only_settings: HttpOnlySettings, - api_key_only_settings: ApiKeyOnlySettings, - mixed_security_settings: MixedSecuritySettings, -) -> None: +def test_instrument_box_reconstructs_concrete_security_scheme_settings() -> None: configurations: list[SwaggerConfig] = [] - for settings in (http_only_settings, api_key_only_settings, mixed_security_settings): + for settings in ( + HttpOnlySettings(security_schemes={"http": OpenApiHttpSecurityScheme(scheme="bearer", bearer_format="JWT")}), + ApiKeyOnlySettings(security_schemes={"api": ApiKeySecurityScheme(name="X-API-Key", location="header")}), + MixedSecuritySettings( + security_schemes={ + "http": OpenApiHttpSecurityScheme(scheme="bearer"), + "api": ApiKeySecurityScheme(name="X-API-Key", location="query"), + } + ), + ): instrument_box = InstrumentBox(__instruments__=[SwaggerInstrument]) instrument_box.initialize(settings) diff --git a/tests/instruments/test_swagger.py b/tests/instruments/test_swagger.py index ad79870..fbdb8d1 100644 --- a/tests/instruments/test_swagger.py +++ b/tests/instruments/test_swagger.py @@ -13,7 +13,13 @@ from microbootstrap.bootstrappers.fastapi import FastApiSwaggerInstrument from microbootstrap.bootstrappers.litestar import LitestarSwaggerInstrument -from microbootstrap.instruments.openapi_security_schemes import OpenApiHttpSecurityScheme +from microbootstrap.instruments.openapi_security_schemes import ( + OpenApiApiKeySecurityScheme, + OpenApiHttpSecurityScheme, + OpenApiOAuth2SecurityScheme, + OpenApiOAuthFlow, + OpenApiOAuthFlows, +) from microbootstrap.instruments.openapi_version_docs import OpenApiOperationVersionOverride, OpenApiVersionDocsConfig from microbootstrap.instruments.swagger_instrument import SwaggerConfig, SwaggerInstrument from microbootstrap.settings import FastApiSettings, LitestarSettings @@ -88,6 +94,41 @@ def test_litestar_swagger_bootstrap_extra_params_have_correct_types(minimal_swag assert type(bootstrap_result["openapi_config"].components) is litestar_openapi.Components +def test_litestar_swagger_builds_native_security_schemes_with_optional_fields() -> None: + swagger_instrument: typing.Final = LitestarSwaggerInstrument( + SwaggerConfig( + security_schemes={ + "http": OpenApiHttpSecurityScheme(scheme="bearer"), + "api-key": OpenApiApiKeySecurityScheme(name="X-API-Key", location="header"), + "oauth": OpenApiOAuth2SecurityScheme( + flows=OpenApiOAuthFlows(resource_owner=OpenApiOAuthFlow(token_url="/token")) # noqa: S106 + ), + } + ) + ) + application: typing.Final = litestar.Litestar(**swagger_instrument.bootstrap_before()) + + swagger_instrument.bootstrap_after(application) + + security_schemes = application.openapi_schema.components.security_schemes + assert security_schemes is not None + http_scheme, api_key_scheme, oauth_scheme = ( + security_schemes["http"], + security_schemes["api-key"], + security_schemes["oauth"], + ) + assert isinstance(http_scheme, litestar_openapi.SecurityScheme) + assert isinstance(api_key_scheme, litestar_openapi.SecurityScheme) + assert isinstance(oauth_scheme, litestar_openapi.SecurityScheme) + assert http_scheme.bearer_format is None + assert http_scheme.description is None + assert api_key_scheme.security_scheme_in == "header" + assert api_key_scheme.description is None + assert oauth_scheme.flows is not None + assert oauth_scheme.flows.password is not None + assert oauth_scheme.flows.password.token_url == "/token" # noqa: S105 + + def test_litestar_swagger_bootstrap_offline_docs(minimal_swagger_config: SwaggerConfig) -> None: minimal_swagger_config.swagger_offline_docs = True swagger_instrument: typing.Final = LitestarSwaggerInstrument(minimal_swagger_config) @@ -186,6 +227,130 @@ def test_fastapi_swagger_bootstrap_working_offline_docs( assert response.status_code == status_codes.HTTP_200_OK +@pytest.mark.parametrize( + ("operation", "error"), + [ + ({"description": "List widgets"}, None), + ( + {"description": "List widgets", "x-accept-versioning": None}, + "x-accept-versioning conflicts", + ), + ({"description": 42}, "non-string description"), + ], +) +def test_fastapi_version_docs_distinguishes_missing_and_invalid_operation_values( + operation: dict[str, object], error: str | None +) -> None: + configuration: typing.Final = SwaggerConfig( + openapi_version_docs=OpenApiVersionDocsConfig( + vendor_media_type="application/vnd.example+json", + supported_versions=("2026-01",), + ) + ) + swagger_instrument: typing.Final = FastApiSwaggerInstrument(configuration) + application: typing.Final = fastapi.FastAPI() + schema: dict[str, typing.Any] = {"paths": {TARGET_PATH: {"get": operation}}} + application.openapi = lambda: schema # type: ignore[method-assign] # Exercise FastAPI's public OpenAPI hook. + + swagger_instrument.bootstrap_after(application) + + if error is not None: + with pytest.raises(ValueError, match=error): + application.openapi() + else: + documented_schema = application.openapi() + assert documented_schema["paths"][TARGET_PATH]["get"]["x-accept-versioning"] == { + "header": "Accept", + "mediaType": "application/vnd.example+json", + "parameter": "version", + "supportedVersions": ["2026-01"], + } + + +def test_fastapi_version_docs_empty_operation_override_leaves_operation_unchanged() -> None: + swagger_instrument: typing.Final = FastApiSwaggerInstrument( + SwaggerConfig( + openapi_version_docs=OpenApiVersionDocsConfig( + vendor_media_type="application/vnd.example+json", + supported_versions=("2026-01",), + operation_versions=( + OpenApiOperationVersionOverride(path=TARGET_PATH, method="get", supported_versions=()), + ), + ) + ) + ) + application: typing.Final = fastapi.FastAPI() + operation: dict[str, typing.Any] = {"description": "List widgets"} + application.openapi = lambda: {"paths": {TARGET_PATH: {"get": operation}}} # type: ignore[method-assign] # Exercise FastAPI's public OpenAPI hook. + + swagger_instrument.bootstrap_after(application) + + assert application.openapi()["paths"][TARGET_PATH]["get"] == {"description": "List widgets"} + + +@pytest.mark.parametrize( + ("schema", "expected_schema"), + [ + ( + { + "openapi": "3.1.0", + "info": {"title": "Service API", "version": "1.0.0"}, + "components": {"schemas": {"Widget": {"type": "object"}}}, + }, + { + "openapi": "3.1.0", + "info": {"title": "Service API", "version": "1.0.0"}, + "components": {"schemas": {"Widget": {"type": "object"}}}, + }, + ), + ( + { + "openapi": "3.1.0", + "info": {"title": "Service API", "version": "1.0.0"}, + "paths": {"x-owner": "widgets", TARGET_PATH: {"get": {"description": "List widgets"}}}, + }, + { + "openapi": "3.1.0", + "info": {"title": "Service API", "version": "1.0.0"}, + "paths": { + "x-owner": "widgets", + TARGET_PATH: { + "get": { + "description": "List widgets\n\nSupported API version: " + "`application/vnd.example+json; version=2026-01`.", + "x-accept-versioning": { + "header": "Accept", + "mediaType": "application/vnd.example+json", + "parameter": "version", + "supportedVersions": ["2026-01"], + }, + } + }, + }, + }, + ), + ], +) +def test_fastapi_version_docs_handles_optional_paths_and_extensions( + schema: dict[str, typing.Any], expected_schema: dict[str, typing.Any] +) -> None: + swagger_instrument: typing.Final = FastApiSwaggerInstrument( + SwaggerConfig( + openapi_version_docs=OpenApiVersionDocsConfig( + vendor_media_type="application/vnd.example+json", + supported_versions=("2026-01",), + ) + ) + ) + application: typing.Final = fastapi.FastAPI() + application.openapi = lambda: schema # type: ignore[method-assign] # Exercise FastAPI's public OpenAPI hook. + + swagger_instrument.bootstrap_after(application) + + assert application.openapi() == expected_schema + assert application.openapi() == expected_schema + + @pytest.mark.parametrize( ("configuration", "error"), [ From 2ac7b6c52a3746f0f7f2b5d410d900d6471db0eb Mon Sep 17 00:00:00 2001 From: Mateo Li Date: Mon, 5 Oct 2026 16:19:21 +0300 Subject: [PATCH 09/10] Fix mypy suppression for dataclass default factory --- microbootstrap/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/microbootstrap/helpers.py b/microbootstrap/helpers.py index e0dffa9..1cae380 100644 --- a/microbootstrap/helpers.py +++ b/microbootstrap/helpers.py @@ -26,7 +26,7 @@ def dataclass_to_dict_no_defaults(dataclass_to_convert: "_DataclassT") -> dict[s if dataclass_field.default != value and isinstance(dataclass_field.default_factory, _MISSING_TYPE): conversion_result[dataclass_field.name] = value continue - if value != dataclass_field.default and value != dataclass_field.default_factory(): # type: ignore[misc] + if value != dataclass_field.default and value != dataclass_field.default_factory(): # type: ignore[operator] conversion_result[dataclass_field.name] = value return conversion_result From adf01070371ced13332104824b46da3048efd74d Mon Sep 17 00:00:00 2001 From: Mateo Li Date: Mon, 5 Oct 2026 16:57:00 +0300 Subject: [PATCH 10/10] Cover OpenAPI augmentation guard paths --- tests/instruments/test_swagger.py | 46 +++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/instruments/test_swagger.py b/tests/instruments/test_swagger.py index fbdb8d1..a76e6da 100644 --- a/tests/instruments/test_swagger.py +++ b/tests/instruments/test_swagger.py @@ -1,3 +1,4 @@ +import copy import typing import fastapi @@ -19,6 +20,8 @@ OpenApiOAuth2SecurityScheme, OpenApiOAuthFlow, OpenApiOAuthFlows, + OpenApiSecuritySchemeModel, + _OpenApiSecurityScheme, ) from microbootstrap.instruments.openapi_version_docs import OpenApiOperationVersionOverride, OpenApiVersionDocsConfig from microbootstrap.instruments.swagger_instrument import SwaggerConfig, SwaggerInstrument @@ -129,6 +132,49 @@ def test_litestar_swagger_builds_native_security_schemes_with_optional_fields() assert oauth_scheme.flows.password.token_url == "/token" # noqa: S105 +def test_litestar_version_docs_preserves_components_only_schema() -> None: + swagger_instrument: typing.Final = LitestarSwaggerInstrument( + SwaggerConfig( + openapi_version_docs=OpenApiVersionDocsConfig( + vendor_media_type="application/vnd.example+json", + supported_versions=("2026-01",), + ) + ) + ) + application: typing.Final = litestar.Litestar(**swagger_instrument.bootstrap_before()) + schema: typing.Final = application.openapi_schema + schema.paths = None + expected_schema: typing.Final = copy.deepcopy(schema.to_schema()) + + swagger_instrument.bootstrap_after(application) + + assert application.openapi_schema is schema + assert schema.paths is None + assert schema.to_schema() == expected_schema + + +def test_litestar_rejects_unsupported_security_scheme_model() -> None: + unsupported_scheme: typing.Final = typing.cast("_OpenApiSecurityScheme", OpenApiSecuritySchemeModel()) + + with pytest.raises(AssertionError, match=r"^Unsupported OpenAPI security scheme\.$"): + LitestarSwaggerInstrument._build_litestar_security_scheme(unsupported_scheme) # noqa: SLF001 + + +def test_swagger_version_documentation_returns_none_when_disabled() -> None: + swagger_instrument: typing.Final = SwaggerInstrument(SwaggerConfig()) + + assert ( + swagger_instrument._build_version_documentation( # noqa: SLF001 + TARGET_PATH, + "get", + "Service-owned description", + {"service": "owned"}, + has_existing_extension=True, + ) + is None + ) + + def test_litestar_swagger_bootstrap_offline_docs(minimal_swagger_config: SwaggerConfig) -> None: minimal_swagger_config.swagger_offline_docs = True swagger_instrument: typing.Final = LitestarSwaggerInstrument(minimal_swagger_config)