From bd796cc4322545c3e00f48a6efc3e8cb89b2eb88 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 6 Aug 2026 19:23:58 -0700 Subject: [PATCH] feat: add pystapi-schema-generator, the reference app and OpenAPI export The conformance gate needs an OpenAPI document to validate against, and until now there was nothing that produced one except the test application, whose document describes the fixtures rather than the API generically. `pystapi-schema-generator` builds a reference application out of the routers and exports its document. It lives outside `tests/`, so what it publishes is the API's own shape rather than a particular test's, and it templatizes the product paths to `{productId}` so a multi-product deployment is described once rather than per fixture. Kept as the last commit on the branch and touching nothing before it: the package, the workspace member and source entries, its place in the mypy and test loops, and the lockfile. It can be lifted off onto its own branch without disturbing the stapi-pydantic and stapi-fastapi releases. --- pyproject.toml | 9 +- pystapi-schema-generator/README.md | 9 + pystapi-schema-generator/pyproject.toml | 29 + .../src/pystapi_schema_generator/__init__.py | 7 + .../pystapi_schema_generator/application.py | 498 ++++++++++++++++++ .../src/pystapi_schema_generator/py.typed | 0 .../tests/test_application.py | 404 ++++++++++++++ scripts/run-mypy.sh | 2 +- scripts/run-tests.sh | 2 +- uv.lock | 39 ++ 10 files changed, 995 insertions(+), 4 deletions(-) create mode 100644 pystapi-schema-generator/README.md create mode 100644 pystapi-schema-generator/pyproject.toml create mode 100644 pystapi-schema-generator/src/pystapi_schema_generator/__init__.py create mode 100644 pystapi-schema-generator/src/pystapi_schema_generator/application.py create mode 100644 pystapi-schema-generator/src/pystapi_schema_generator/py.typed create mode 100644 pystapi-schema-generator/tests/test_application.py diff --git a/pyproject.toml b/pyproject.toml index 3066f22..7eddbea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "pystapi-validator", "stapi-pydantic", "stapi-fastapi", + "pystapi-schema-generator", ] [dependency-groups] @@ -19,6 +20,8 @@ dev = [ "pre-commit>=4.2.0", "pre-commit-hooks>=5.0.0", "pygithub>=2.6.1", + "pyyaml>=6.0", + "types-pyyaml>=6.0", ] docs = [ "mkdocs-material>=9.6.11", @@ -29,13 +32,14 @@ docs = [ default-groups = ["dev", "docs"] [tool.uv.workspace] -members = ["pystapi-validator", "stapi-pydantic", "pystapi-client", "stapi-fastapi"] +members = ["pystapi-validator", "stapi-pydantic", "pystapi-client", "stapi-fastapi", "pystapi-schema-generator"] [tool.uv.sources] pystapi-client.workspace = true pystapi-validator.workspace = true stapi-pydantic.workspace = true stapi-fastapi.workspace = true +pystapi-schema-generator.workspace = true [tool.ruff] line-length = 120 @@ -62,7 +66,8 @@ files = [ "pystapi-client/src/pystapi_client/**/*.py", "pystapi-validator/src/pystapi_validator/**/*.py", "stapi-pydantic/src/stapi_pydantic/**/*.py", - "stapi-fastapi/src/stapi_fastapi/**/*.py" + "stapi-fastapi/src/stapi_fastapi/**/*.py", + "pystapi-schema-generator/src/pystapi_schema_generator/**/*.py" ] [[tool.mypy.overrides]] diff --git a/pystapi-schema-generator/README.md b/pystapi-schema-generator/README.md new file mode 100644 index 0000000..5dbb726 --- /dev/null +++ b/pystapi-schema-generator/README.md @@ -0,0 +1,9 @@ +# pystapi-schema-generator + +A minimal reference STAPI application and console script for exporting its OpenAPI document as YAML. + +## Usage + +```bash +pystapi-schema-generator > openapi.yaml +``` diff --git a/pystapi-schema-generator/pyproject.toml b/pystapi-schema-generator/pyproject.toml new file mode 100644 index 0000000..5058e52 --- /dev/null +++ b/pystapi-schema-generator/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "pystapi-schema-generator" +version = "0.1.0" +description = "Reference STAPI application and OpenAPI schema export tooling" +readme = "README.md" +license = "MIT" +authors = [ + { name = "Jarrett Keifer", email = "jkeifer0@gmail.com" }, +] +requires-python = ">=3.11" +dependencies = [ + "stapi-fastapi>=0.9.0", + "pyyaml>=6.0", +] + +[project.scripts] +pystapi-schema-generator = "pystapi_schema_generator.application:main" + +[dependency-groups] +dev = [ + "pytest>=8.3.5", +] + +[tool.uv.sources] +stapi-fastapi = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/__init__.py b/pystapi-schema-generator/src/pystapi_schema_generator/__init__.py new file mode 100644 index 0000000..3c7dff4 --- /dev/null +++ b/pystapi-schema-generator/src/pystapi_schema_generator/__init__.py @@ -0,0 +1,7 @@ +from .application import create_reference_app, export_openapi, main + +__all__ = [ + "create_reference_app", + "export_openapi", + "main", +] diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/application.py b/pystapi-schema-generator/src/pystapi_schema_generator/application.py new file mode 100644 index 0000000..ea274ff --- /dev/null +++ b/pystapi-schema-generator/src/pystapi_schema_generator/application.py @@ -0,0 +1,498 @@ +"""A minimal, generic FastAPI app dedicated to OpenAPI spec export. + +A STAPI application wired with a single, generically-named example product and +stub backends for every optional capability. Schema export only introspects +routes and models, so the backends simply raise ``NotImplementedError``. + +The base model classes are used throughout, and nothing is imported from +``tests/``, so the exported document describes the API generically rather than +leaking fixture names. +""" + +import json +import sys +from copy import deepcopy +from typing import Any, NoReturn + +import yaml +from fastapi import FastAPI, status +from pydantic import ConfigDict +from stapi_fastapi.conformance import API, PRODUCT +from stapi_fastapi.models.product import Product +from stapi_fastapi.routers.base import NOT_FOUND, operation_id +from stapi_fastapi.routers.root_router import RootRouter +from stapi_pydantic import ( + STAPI_VERSION, + OpportunityProperties, + Provider, + ProviderRole, + Queryables, +) +from stapi_pydantic import ( + OrderParameters as _StrictOrderParameters, +) + + +# The docstring below is published verbatim as the component's `description`, so +# it is written for a spec reader. +# +# `stapi_pydantic.OrderParameters` is strict (no fields, `extra="forbid"`), which +# in a generic document would publish `additionalProperties: false` with no +# properties -- a spec in which `{}` is the only legal value. Subclassing rather +# than substituting `BaseOrderParameters` keeps the `ORP` bound satisfied and the +# component name unchanged. +class OrderParameters(_StrictOrderParameters): + """Product-specific parameters to apply when creating an Order. + + Each Product defines its own Order Parameters JSON Schema, published at + ``GET /products/{productId}/order-parameters``; this value must validate against + that schema. Omitting it is equivalent to providing an empty object. + """ + + model_config = ConfigDict(extra="allow") + + +#: The concrete product id of the single reference product, scrubbed from the +#: published document during post-processing. +_PRODUCT_ID = "example" + +#: The name the reference app's root router is mounted under; every operationId +#: begins with it. +_ROOT_ROUTER_NAME = "root" + +PRODUCT_ID_PARAMETER: dict[str, Any] = { + "name": "productId", + "in": "path", + "required": True, + "schema": {"type": "string", "title": "Product Id"}, +} + +_HTTP_METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"} + +_SCHEMA_REF_PREFIX = "#/components/schemas/" + +_SPEC_DOCS = "https://github.com/stapi-spec/stapi-spec/blob/main/docs" + +#: API-level conformance classes the reference app advertises. Imported rather +#: than hardcoded so the published URIs track the STAPI version. +_ADVERTISED_CONFORMANCE: list[str] = [ + API.core, + API.order_statuses, + API.searches_opportunity, + API.searches_opportunity_statuses, +] + + +async def _not_implemented(*args: Any, **kwargs: Any) -> NoReturn: + """Stub backend. Never called during schema export.""" + raise NotImplementedError + + +def create_reference_app() -> FastAPI: + """Build the generic reference STAPI application used for OpenAPI export.""" + provider = Provider( + name="Example Provider", + description="Example provider for demonstration purposes", + roles=[ProviderRole.producer], + url="https://example.com/provider", + ) + + example_product = Product( + id=_PRODUCT_ID, + title="Example Product", + description=( + "This is an example product that demonstrates the STAPI specification. " + "Implementers should replace this with their actual product definitions, " + "including specific metadata, queryable properties, and order parameters." + ), + license="proprietary", + keywords=["example"], + providers=[provider], + links=[], + create_order=_not_implemented, + search_opportunities=_not_implemented, + search_opportunities_async=_not_implemented, + get_opportunity_collection=_not_implemented, + queryables=Queryables, + opportunity_properties=OpportunityProperties, + order_parameters=OrderParameters, + conforms_to=[PRODUCT.geojson_point, PRODUCT.opportunities, PRODUCT.opportunities_async], + ) + + root_router = RootRouter( + get_orders=_not_implemented, + get_order=_not_implemented, + get_order_statuses=_not_implemented, + get_opportunity_search_records=_not_implemented, + get_opportunity_search_record=_not_implemented, + get_opportunity_search_record_statuses=_not_implemented, + conformances=list(_ADVERTISED_CONFORMANCE), + name=_ROOT_ROUTER_NAME, + ) + root_router.add_product(example_product) + + app: FastAPI = FastAPI( + title="STAPI", + description=( + "The Sensor Tasking API (STAPI) defines a JSON-based web API to query for " + "spatio-temporal analytic and data products derived from remote sensing " + "(satellite or airborne) providers. The specification supports both products " + "derived from new tasking and products from provider archives." + ), + version=STAPI_VERSION, + contact={ + "name": "STAPI Specification Organization", + "url": "https://github.com/stapi-spec", + }, + openapi_tags=[ + { + "name": "Root", + "description": "The landing page, communicating API metadata, conformance, and links.", + "externalDocs": { + "description": "STAPI Core Specification", + "url": f"{_SPEC_DOCS}/conformances/core/README.md", + }, + }, + { + "name": "Conformance", + "description": "Conformance classes implemented by this API.", + "externalDocs": { + "description": "STAPI Conformance Classes", + "url": f"{_SPEC_DOCS}/conformances/README.md", + }, + }, + { + "name": "Products", + "description": "Endpoints for discovering and describing remote sensing data products.", + "externalDocs": { + "description": "STAPI Product Specification", + "url": f"{_SPEC_DOCS}/spec/product/README.md", + }, + }, + { + "name": "Orders", + "description": "Endpoints for creating and monitoring remote sensing data orders.", + "externalDocs": { + "description": "STAPI Order Specification", + "url": f"{_SPEC_DOCS}/spec/order/README.md", + }, + }, + { + "name": "Opportunities", + "description": "Endpoints for searching remote sensing acquisition opportunities.", + "externalDocs": { + "description": "STAPI Opportunity Specification", + "url": f"{_SPEC_DOCS}/spec/opportunity/README.md", + }, + }, + ], + ) + app.include_router(root_router, prefix="") + + _original_openapi = app.openapi + + def _openapi_with_external_docs() -> dict[str, Any]: + schema = _original_openapi() + schema["externalDocs"] = { + "description": "STAPI Specification Documentation", + "url": "https://stapi-spec.github.io/stapi-spec/", + } + return schema + + app.openapi = _openapi_with_external_docs # type: ignore[method-assign] + + return app + + +def _operations(openapi: dict[str, Any]) -> list[dict[str, Any]]: + """Every operation object in the document.""" + return [ + operation + for path_item in openapi["paths"].values() + for method, operation in path_item.items() + if method in _HTTP_METHODS and isinstance(operation, dict) + ] + + +def _genericize_operation_ids(openapi: dict[str, Any]) -> None: + """Strip this app's own names out of the published operationIds. + + ``root_example_create_order`` becomes ``create_order``, the way + :func:`_templatize_product_paths` makes the paths lose them. Dropping the + product segment can make a product-scoped id collide with a root-level one; + those keep a ``product_`` prefix (``product_conformance``). + """ + root_prefix = operation_id(f"{_ROOT_ROUTER_NAME}:") + product_prefix = operation_id(f"{_ROOT_ROUTER_NAME}:{_PRODUCT_ID}:") + + def strip(published: str) -> tuple[str, bool]: + if published.startswith(product_prefix): + return published[len(product_prefix) :], True + if published.startswith(root_prefix): + return published[len(root_prefix) :], False + return published, False + + operations = _operations(openapi) + stripped = [strip(operation["operationId"]) for operation in operations] + root_level = {base for base, product_scoped in stripped if not product_scoped} + + for operation, (base, product_scoped) in zip(operations, stripped): + operation["operationId"] = f"product_{base}" if product_scoped and base in root_level else base + + +def _base_schema_name(name: str, schema: dict[str, Any]) -> str: + """Return a clean base name for a (possibly generic) schema. + + Pydantic names generic-model schemas after their parameterization + (``Order_OrderStatus_``), but the ``title`` carries the readable form + (``Order[OrderStatus]``), so the clean base is the identifier before the + first ``[``. FastAPI's ``-Input`` / ``-Output`` suffixes are preserved. + """ + for suffix in ("-Input", "-Output"): + if name.endswith(suffix): + return _base_schema_name(name[: -len(suffix)], schema) + suffix + title: str = schema.get("title", "") or "" + if "[" in title: + return title.split("[", 1)[0] + return name + + +def _schema_name(ref: Any) -> str | None: + """Resolve a local component reference to its schema name. + + Accepts both spellings a reference can take in the document: a full + ``#/components/schemas/`` pointer, and the bare ```` that OpenAPI + also permits for ``discriminator.mapping`` values. + """ + if not isinstance(ref, str): + return None + if ref.startswith(_SCHEMA_REF_PREFIX): + return ref[len(_SCHEMA_REF_PREFIX) :] + return ref if "/" not in ref and "#" not in ref else None + + +def _local_ref_slots(node: dict[str, Any]) -> list[tuple[dict[str, Any], Any]]: + """Return the ``(container, key)`` slots in ``node`` that hold a local reference. + + A node references components in two places: its own ``$ref``, and the values + of a ``discriminator.mapping``. The latter are bare strings, so a traversal + that only looks for ``$ref`` silently misses them -- and the exported document + carries such a mapping on every ``geometry`` field. + """ + slots: list[tuple[dict[str, Any], Any]] = [(node, "$ref")] if "$ref" in node else [] + discriminator = node.get("discriminator") + mapping = discriminator.get("mapping") if isinstance(discriminator, dict) else None + if isinstance(mapping, dict): + slots.extend((mapping, key) for key in mapping) + return slots + + +def _rewrite_refs(node: Any, rename: dict[str, str]) -> Any: + """Recursively rewrite local reference schema names according to ``rename``.""" + if isinstance(node, dict): + for container, key in _local_ref_slots(node): + name = _schema_name(container[key]) + if name is not None and name in rename: + container[key] = _SCHEMA_REF_PREFIX + rename[name] + for value in node.values(): + _rewrite_refs(value, rename) + elif isinstance(node, list): + for item in node: + _rewrite_refs(item, rename) + return node + + +def _canonical(schema: dict[str, Any]) -> str: + """Deterministic, title-insensitive signature for deduplication.""" + without_title = {k: v for k, v in schema.items() if k != "title"} + return json.dumps(without_title, sort_keys=True) + + +def _assign_clean_names(schemas: dict[str, Any]) -> dict[str, str]: + """Map each current schema name to its clean, unique target name. + + Schemas that collapse to the same base name are disambiguated: FastAPI's + ``-Input`` / ``-Output`` validation/serialization pairs keep that suffix; + any other genuine collision gets a stable numeric suffix ordered by the + schema's canonical (title-insensitive) signature. + """ + groups: dict[str, list[str]] = {} + for name, schema in schemas.items(): + groups.setdefault(_base_schema_name(name, schema), []).append(name) + + rename: dict[str, str] = {} + for base, members in groups.items(): + if len(members) == 1: + rename[members[0]] = base + continue + io_members = [m for m in members if m in (base + "-Input", base + "-Output")] + if len(io_members) == len(members): + for m in io_members: + rename[m] = m # already a clean, distinct Input/Output name + continue + for index, m in enumerate(sorted(members, key=lambda m: (_canonical(schemas[m]), m))): + rename[m] = base if index == 0 else f"{base}-{index + 1}" + return rename + + +def _dedup_identical(schemas: dict[str, Any]) -> dict[str, str]: + """Return a ``duplicate -> survivor`` map for title-insensitive duplicates. + + Names carrying a mode suffix sort last, so that a duplicate pair never elects + ``X-Input`` as the survivor and publishes the suffix as if it meant something. + """ + signatures: dict[str, str] = {} + dedup: dict[str, str] = {} + for name in sorted(schemas, key=lambda n: (n.endswith(("-Input", "-Output")), n)): + signature = _canonical(schemas[name]) + if signature in signatures: + dedup[name] = signatures[signature] + else: + signatures[signature] = name + return dedup + + +def _clean_schema_names(openapi: dict[str, Any]) -> None: + """Give component schemas readable, generic, deterministic names. + + Collapses Pydantic generic-parameter mangling to the base model name, + deduplicates structurally identical schemas, and rewrites every ``$ref`` + consistently. Iterates to a fixpoint because collapsing one model can make + its containers identical too. + """ + schemas: dict[str, Any] = openapi["components"]["schemas"] + paths = openapi["paths"] + + while True: + rename = _assign_clean_names(schemas) + renamed: dict[str, Any] = {} + for old, schema in schemas.items(): + schema = {**schema, "title": rename[old]} + renamed[rename[old]] = schema + _rewrite_refs(renamed, rename) + _rewrite_refs(paths, rename) + schemas = renamed + + dedup = _dedup_identical(schemas) + for name in dedup: + del schemas[name] + _rewrite_refs(schemas, dedup) + _rewrite_refs(paths, dedup) + + if not any(old != new for old, new in rename.items()) and not dedup: + break + + openapi["components"]["schemas"] = dict(sorted(schemas.items())) + + +def _merge_input_output_pairs(openapi: dict[str, Any]) -> None: + """Collapse ``X-Input``/``X-Output`` pairs that describe the same thing. + + FastAPI asks pydantic for both a validation and a serialization schema when a + model appears in a request and a response. Pydantic re-merges them unless that + would be ambiguous, which it always looks for a self-recursive model, so the + split cascades to everything containing a geometry. + + Merging here rather than disabling FastAPI's ``separate_input_output_schemas``, + which would generate the whole document in validation mode and so drop every + serialization alias and serialization-required field. + + The candidate set starts optimistic and shrinks: a pair survives only if the + two members are identical *once the merge is applied*. + """ + schemas: dict[str, Any] = openapi["components"]["schemas"] + bases = { + name[: -len("-Input")] + for name in schemas + if name.endswith("-Input") and f"{name[: -len('-Input')]}-Output" in schemas + } + + while bases: + rename = {f"{base}{suffix}": base for base in bases for suffix in ("-Input", "-Output")} + divergent = { + base + for base in bases + if _canonical(_rewrite_refs(deepcopy(schemas[f"{base}-Input"]), rename)) + != _canonical(_rewrite_refs(deepcopy(schemas[f"{base}-Output"]), rename)) + } + if not divergent: + break + bases -= divergent + + if not bases: + return + + rename = {f"{base}{suffix}": base for base in bases for suffix in ("-Input", "-Output")} + for base in bases: + schemas[base] = {**schemas.pop(f"{base}-Output"), "title": base} + del schemas[f"{base}-Input"] + _rewrite_refs(schemas, rename) + _rewrite_refs(openapi["paths"], rename) + openapi["components"]["schemas"] = dict(sorted(schemas.items())) + + +def _templatize_product_paths(openapi: dict[str, Any]) -> dict[str, Any]: + """Rewrite the concrete product paths into templated form. + + ``/products/{id}`` -> ``/products/{productId}`` and + ``/products/{id}/...`` -> ``/products/{productId}/...``, injecting a + ``productId`` path parameter into each operation. + """ + concrete = f"/products/{_PRODUCT_ID}" + paths: dict[str, Any] = openapi["paths"] + new_paths: dict[str, Any] = {} + + for path, path_item in paths.items(): + if path == concrete: + new_path = "/products/{productId}" + elif path.startswith(concrete + "/"): + new_path = "/products/{productId}/" + path[len(concrete + "/") :] + else: + new_paths[path] = path_item + continue + + path_item = deepcopy(path_item) + for method, operation in path_item.items(): + if method not in _HTTP_METHODS or not isinstance(operation, dict): + continue + parameters = operation.setdefault("parameters", []) + parameters.insert(0, deepcopy(PRODUCT_ID_PARAMETER)) + # the reference app's one concrete product declares no 404, but the + # templated form can be asked for a product that does not exist + operation.setdefault("responses", {}).setdefault( + str(status.HTTP_404_NOT_FOUND), + deepcopy(NOT_FOUND[status.HTTP_404_NOT_FOUND]), + ) + if new_path in new_paths: + raise AssertionError( + f"{new_path} was produced twice: the reference app must register exactly one " + "product, otherwise templatizing collapses several products onto one path and " + "silently publishes only the last." + ) + new_paths[new_path] = path_item + + openapi["paths"] = new_paths + return openapi + + +def export_openapi() -> dict[str, Any]: + """Build the reference app and return its post-processed OpenAPI schema.""" + # The passes below mutate in place, and ``app.openapi()`` returns FastAPI's + # cached document, so copy first: without this a second call would + # post-process an already-post-processed document. + openapi = deepcopy(create_reference_app().openapi()) + + _merge_input_output_pairs(openapi) + _clean_schema_names(openapi) + _genericize_operation_ids(openapi) + _templatize_product_paths(openapi) + return openapi + + +def main() -> None: + """Write the exported OpenAPI document as YAML to stdout.""" + sys.stdout.write(yaml.safe_dump(export_openapi(), sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/py.typed b/pystapi-schema-generator/src/pystapi_schema_generator/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pystapi-schema-generator/tests/test_application.py b/pystapi-schema-generator/tests/test_application.py new file mode 100644 index 0000000..6ac2d50 --- /dev/null +++ b/pystapi-schema-generator/tests/test_application.py @@ -0,0 +1,404 @@ +import os +import subprocess +import sys +from typing import Any + +from pystapi_schema_generator.application import ( + _HTTP_METHODS, + _local_ref_slots, + _merge_input_output_pairs, + _rewrite_refs, + _schema_name, + export_openapi, +) +from stapi_fastapi.conformance import API +from stapi_pydantic import STAPI_VERSION + +EXPECTED_PATHS = { + "/", + "/conformance", + "/products", + "/products/{productId}", + "/products/{productId}/conformance", + "/products/{productId}/queryables", + "/products/{productId}/order-parameters", + "/products/{productId}/orders", + "/products/{productId}/opportunities", + "/products/{productId}/opportunities/{opportunityCollectionId}", + "/orders", + "/orders/{orderId}", + "/orders/{orderId}/statuses", + "/searches/opportunities", + "/searches/opportunities/{searchRecordId}", + "/searches/opportunities/{searchRecordId}/statuses", +} + +# Full inventory of the exported component schemas. Any silently dropped or +# renamed schema must fail here, guarding CI against upstream drift. +EXPECTED_SCHEMA_NAMES = { + "BaseOrderParameters", + "Conformance", + "HTTPValidationError", + "JsonSchema", + "LineString", + "Link", + "MultiLineString", + "MultiPoint", + "MultiPolygon", + "Opportunity", + "OpportunityCollection", + "OpportunityProperties", + "OpportunityRequest", + "OpportunitySearchRecord", + "OpportunitySearchRecordCollection", + "OpportunitySearchStatus", + "OpportunitySearchStatusCode", + "OpportunitySearchStatusCollection", + "Order", + "OrderCollection", + "OrderParameters", + "OrderProperties", + "OrderRequest", + "OrderStatus", + "OrderStatusCode", + "OrderStatusCollection", + "Point", + "Polygon", + "Position2D", + "Position3D", + "Product", + "ProductCollection", + "Provider", + "ProviderRole", + "RootResponse", + "SearchParameters", + "StoredOrderRequest", + "ValidationError", +} + + +def _operations(schema: dict[str, Any]) -> list[dict[str, Any]]: + return [ + op + for path_item in schema["paths"].values() + for method, op in path_item.items() + if method in _HTTP_METHODS and isinstance(op, dict) + ] + + +def test_path_inventory_matches_spec_endpoints() -> None: + assert set(export_openapi()["paths"]) == EXPECTED_PATHS + + +def test_schema_inventory_matches_snapshot() -> None: + schemas = export_openapi()["components"]["schemas"] + assert set(schemas) == EXPECTED_SCHEMA_NAMES + + +def test_no_concrete_example_product_leakage() -> None: + """The concrete product id must not leak into any path, operationId, or + schema key/title. It may still legitimately appear in prose descriptions + and conformance URIs (example.com), so scope to identifiers only.""" + schema = export_openapi() + + for path in schema["paths"]: + assert "example" not in path.lower(), f"leaked in path {path}" + + for op in _operations(schema): + operation_id = op.get("operationId", "") + assert "example" not in operation_id.lower(), f"leaked in operationId {operation_id}" + + for name, component in schema["components"]["schemas"].items(): + assert "example" not in name.lower(), f"leaked in schema name {name}" + assert "example" not in component.get("title", "").lower(), f"leaked in title of {name}" + + +def test_operation_ids_are_clean_and_generic() -> None: + schema = export_openapi() + ids = {op["operationId"] for op in _operations(schema) if "operationId" in op} + # Generic, readable ids for the core operations. + assert {"get_product", "create_order", "search_opportunities"} <= ids + # No FastAPI default mangling (router prefix + path + method). + for operation_id in ids: + assert "products_" not in operation_id + assert not operation_id.startswith("root_") + # All operationIds are unique. + id_list = [op["operationId"] for op in _operations(schema) if "operationId" in op] + assert len(id_list) == len(set(id_list)) + + +def test_templated_operations_declare_product_id_param() -> None: + schema = export_openapi() + for path, ops in schema["paths"].items(): + if "{productId}" not in path: + continue + for op in ops.values(): + names = {p["name"] for p in op.get("parameters", []) if p.get("in") == "path"} + assert "productId" in names, f"missing productId param on {path}" + + +def test_info_and_external_docs() -> None: + schema = export_openapi() + assert schema["info"]["title"] == "STAPI" + assert schema["info"]["version"] == STAPI_VERSION + assert schema["externalDocs"]["url"] == "https://stapi-spec.github.io/stapi-spec/" + + +# --- Exported document reflects the upstream model/router fixes ------------- + + +def test_order_response_marks_spec_required_fields() -> None: + order = export_openapi()["components"]["schemas"]["Order"] + required = set(order.get("required", [])) + for field in ("stapi_type", "stapi_version", "type", "links", "bbox"): + assert field in required, f"{field} not required on Order response" + + +def test_order_bbox_has_no_null_branch() -> None: + order = export_openapi()["components"]["schemas"]["Order"] + bbox = order["properties"]["bbox"] + # bbox is non-nullable: the anyOf branches are the 2D/3D tuples, no null. + assert "null" not in str(bbox).lower() + for branch in bbox.get("anyOf", []): + assert branch.get("type") != "null" + + +def test_no_required_property_is_nullable() -> None: + """A required property must never permit null. + + `json_schema_serialization_defaults_required` marks every defaulted field as + required, so an optional-and-nullable one needs `exclude_if` or it silently + becomes wrongly required. + """ + + def is_nullable(prop: dict[str, Any]) -> bool: + if prop.get("type") == "null": + return True + return any(branch.get("type") == "null" for branch in prop.get("anyOf", [])) + + offenders = [ + f"{name}.{prop_name}" + for name, schema in export_openapi()["components"]["schemas"].items() + for prop_name in schema.get("required", []) + if is_nullable(schema.get("properties", {}).get(prop_name, {})) + ] + assert not offenders, f"required properties that permit null: {sorted(offenders)}" + + +def test_create_order_201_documents_location_header() -> None: + responses = export_openapi()["paths"]["/products/{productId}/orders"]["post"]["responses"] + created = responses["201"] + assert "Location" in created["headers"] + assert "application/geo+json" in created["content"] + + +def test_async_search_201_is_json_only_with_location() -> None: + responses = export_openapi()["paths"]["/products/{productId}/opportunities"]["post"]["responses"] + created = responses["201"] + assert set(created["content"]) == {"application/json"} + assert "Location" in created["headers"] + + +def test_order_status_code_allows_arbitrary_strings() -> None: + """The order status schema's status_code must accept arbitrary strings + (anyOf of the enum and a bare string), not just the enum.""" + order_status = export_openapi()["components"]["schemas"]["OrderStatus"] + status_code = order_status["properties"]["status_code"] + branch_kinds = status_code.get("anyOf", []) + has_enum = any("$ref" in b for b in branch_kinds) + has_string = any(b.get("type") == "string" for b in branch_kinds) + assert has_enum and has_string, status_code + + +# --- Cleaned document invariants ------------------------------------------- + + +def test_no_orphan_base_model_component() -> None: + """No pass drops this: the queryables endpoints return a `JsonSchema`, so no + annotation names `BaseModel` for FastAPI to register.""" + schemas = export_openapi()["components"]["schemas"] + assert "BaseModel" not in schemas + + +def test_all_component_schemas_are_referenced() -> None: + schema = export_openapi() + referenced: set[str] = set() + + def collect(node: Any) -> None: + if isinstance(node, dict): + # via _local_ref_slots rather than the `$ref` key alone: a + # `discriminator.mapping` names its targets as bare strings, and + # every geometry field carries one. + referenced.update( + name for container, key in _local_ref_slots(node) if (name := _schema_name(container[key])) is not None + ) + for value in node.values(): + collect(value) + elif isinstance(node, list): + for item in node: + collect(item) + + collect(schema["paths"]) + collect(schema["components"]["schemas"]) + orphans = set(schema["components"]["schemas"]) - referenced + assert not orphans, f"unreferenced component schemas: {sorted(orphans)}" + + +def test_no_dangling_refs() -> None: + schema = export_openapi() + names = set(schema["components"]["schemas"]) + dangling: set[str] = set() + + def collect(node: Any) -> None: + if isinstance(node, dict): + ref = node.get("$ref") + if isinstance(ref, str) and ref.startswith("#/components/schemas/"): + target = ref[len("#/components/schemas/") :] + if target not in names: + dangling.add(target) + for value in node.values(): + collect(value) + elif isinstance(node, list): + for item in node: + collect(item) + + collect(schema) + assert not dangling, f"dangling $refs: {sorted(dangling)}" + + +def test_conformsto_carries_a_conformance_uri_example() -> None: + """A document naming no conformance class leaves a reader nothing to + recognize. The example comes from the models, so every deployment has one. + """ + schemas = export_openapi()["components"]["schemas"] + for name in ("RootResponse", "Conformance"): + examples = schemas[name]["properties"]["conformsTo"].get("examples") + assert examples, f"{name}.conformsTo has no example" + assert API.core in examples[0] + + +def test_key_component_schemas_present() -> None: + components = export_openapi()["components"]["schemas"] + for name in ( + "StoredOrderRequest", + "OrderStatusCollection", + "OpportunitySearchRecordCollection", + "OpportunitySearchStatusCollection", + ): + assert name in components + + +def test_export_is_deterministic() -> None: + a: dict[str, Any] = export_openapi() + b: dict[str, Any] = export_openapi() + assert a == b + + +def test_export_is_deterministic_across_hash_seeds() -> None: + """Run the console script in fresh subprocesses with different hash seeds + and require byte-identical output.""" + + def run(seed: str) -> bytes: + result = subprocess.run( + [sys.executable, "-m", "pystapi_schema_generator.application"], + capture_output=True, + check=True, + env={**os.environ, "PYTHONHASHSEED": seed}, + ) + return result.stdout + + assert run("0") == run("123456789") + + +def test_rewrite_refs_updates_discriminator_mappings() -> None: + """Renames must follow ``discriminator.mapping``, whose values are bare ref + strings a ``$ref``-only rewriter would leave pointing at the old name. + """ + node: dict[str, Any] = { + "oneOf": [{"$ref": "#/components/schemas/Old"}], + "discriminator": {"propertyName": "type", "mapping": {"Old": "#/components/schemas/Old"}}, + } + + _rewrite_refs(node, {"Old": "New"}) + + assert node["oneOf"][0]["$ref"] == "#/components/schemas/New" + assert node["discriminator"]["mapping"]["Old"] == "#/components/schemas/New" + + +def test_local_ref_slots_sees_discriminator_mappings() -> None: + """A reference held in a ``discriminator.mapping`` is a reference like any other.""" + node = {"discriminator": {"propertyName": "type", "mapping": {"Only": "#/components/schemas/Only"}}} + + found = {name for container, key in _local_ref_slots(node) if (name := _schema_name(container[key])) is not None} + + assert found == {"Only"} + + +def test_order_parameters_is_open_in_the_generic_document() -> None: + """The published ``order_parameters`` schema must not forbid all properties. + + Exporting the strict boundary base would publish a spec in which ``{}`` is the + only legal value, forbidding the feature outright. + """ + schemas = export_openapi()["components"]["schemas"] + + assert schemas["OrderParameters"]["additionalProperties"] is True + + +def test_order_request_references_order_parameters_not_the_permissive_base() -> None: + """``OrderParameters`` and ``BaseOrderParameters`` must stay distinct components. + + Once ``OrderParameters`` is open the two are structurally identical, so + ``_dedup_identical`` keeps them apart on their descriptions alone: dropping + either docstring silently merges them. + """ + schemas = export_openapi()["components"]["schemas"] + + assert "BaseOrderParameters" in schemas + assert schemas["OrderRequest"]["properties"]["order_parameters"] == {"$ref": "#/components/schemas/OrderParameters"} + + +def test_no_input_output_schema_variants_are_published() -> None: + """The document must not expose pydantic's validation/serialization split. + + "Input" and "Output" appear nowhere in the STAPI vocabulary. + """ + schemas = export_openapi()["components"]["schemas"] + + assert [name for name in schemas if name.endswith(("-Input", "-Output"))] == [] + assert {"SearchParameters", "OpportunityRequest"} <= set(schemas) + + +def test_merge_keeps_divergent_pairs_apart() -> None: + """The merge must never collapse two variants that genuinely differ.""" + openapi = { + "components": { + "schemas": { + "Same-Input": {"type": "object"}, + "Same-Output": {"type": "object"}, + "Differs-Input": {"type": "object", "required": ["a"]}, + "Differs-Output": {"type": "object", "required": ["b"]}, + } + }, + "paths": {}, + } + + _merge_input_output_pairs(openapi) + + assert set(openapi["components"]["schemas"]) == {"Same", "Differs-Input", "Differs-Output"} + + +def test_product_scoped_operations_declare_a_missing_product() -> None: + """Templatizing is what makes an unknown product addressable, so the templated + paths must document a 404 the concrete routes rightly do not. + """ + paths = export_openapi()["paths"] + templated = { + (path, method) + for path, item in paths.items() + if "{productId}" in path + for method, operation in item.items() + if method in _HTTP_METHODS and isinstance(operation, dict) and "404" not in operation["responses"] + } + + assert templated == set() diff --git a/scripts/run-mypy.sh b/scripts/run-mypy.sh index c944518..2cf9689 100755 --- a/scripts/run-mypy.sh +++ b/scripts/run-mypy.sh @@ -8,7 +8,7 @@ set -Eeuo pipefail failed=() -for path in stapi-fastapi pystapi-validator pystapi-client stapi-pydantic; do +for path in stapi-fastapi pystapi-validator pystapi-client stapi-pydantic pystapi-schema-generator; do name=$(basename "$path") set +e diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 00ef2bc..05446c7 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -2,7 +2,7 @@ set -Eeuo pipefail # set -x # print each command before executing -for path in stapi-fastapi pystapi-validator pystapi-client stapi-pydantic; do +for path in stapi-fastapi pystapi-validator pystapi-client stapi-pydantic pystapi-schema-generator; do name=$(basename "$path") set +e diff --git a/uv.lock b/uv.lock index 3e3faed..1c4cf32 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,7 @@ requires-python = ">=3.11" members = [ "pystapi", "pystapi-client", + "pystapi-schema-generator", "pystapi-validator", "stapi-fastapi", "stapi-pydantic", @@ -1874,6 +1875,7 @@ version = "0.0.0" source = { virtual = "." } dependencies = [ { name = "pystapi-client" }, + { name = "pystapi-schema-generator" }, { name = "pystapi-validator" }, { name = "stapi-fastapi" }, { name = "stapi-pydantic" }, @@ -1886,7 +1888,9 @@ dev = [ { name = "pre-commit-hooks" }, { name = "pygithub" }, { name = "pymarkdownlnt" }, + { name = "pyyaml" }, { name = "ruff" }, + { name = "types-pyyaml" }, ] docs = [ { name = "mkdocs-material" }, @@ -1896,6 +1900,7 @@ docs = [ [package.metadata] requires-dist = [ { name = "pystapi-client", editable = "pystapi-client" }, + { name = "pystapi-schema-generator", editable = "pystapi-schema-generator" }, { name = "pystapi-validator", editable = "pystapi-validator" }, { name = "stapi-fastapi", editable = "stapi-fastapi" }, { name = "stapi-pydantic", editable = "stapi-pydantic" }, @@ -1908,7 +1913,9 @@ dev = [ { name = "pre-commit-hooks", specifier = ">=5.0.0" }, { name = "pygithub", specifier = ">=2.6.1" }, { name = "pymarkdownlnt", specifier = ">=0.9.25" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", specifier = ">=0.11.2" }, + { name = "types-pyyaml", specifier = ">=6.0" }, ] docs = [ { name = "mkdocs-material", specifier = ">=9.6.11" }, @@ -1948,6 +1955,29 @@ dev = [ { name = "types-click", specifier = ">=7.1.8" }, ] +[[package]] +name = "pystapi-schema-generator" +version = "0.1.0" +source = { editable = "pystapi-schema-generator" } +dependencies = [ + { name = "pyyaml" }, + { name = "stapi-fastapi" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "pyyaml", specifier = ">=6.0" }, + { name = "stapi-fastapi", editable = "stapi-fastapi" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.3.5" }] + [[package]] name = "pystapi-validator" version = "0.1.0" @@ -2764,6 +2794,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/ad/607454a5f991c5b3e14693a7113926758f889138371058a5f72f567fa131/types_click-7.1.8-py3-none-any.whl", hash = "sha256:8cb030a669e2e927461be9827375f83c16b8178c365852c060a34e24871e7e81", size = 12929, upload-time = "2021-11-23T12:27:59.493Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260724" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/6f/a28f44bcd56bebed42b028a2894c79853e2f5e6b5279e633cb3f287a05e7/types_pyyaml-6.0.12.20260724.tar.gz", hash = "sha256:3c1ce1bb73cd5ec02e90390c2b1f00e810d241d8825fd73ff359696839271b6b", size = 17893, upload-time = "2026-07-24T04:58:43.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/42/0337fefc615e20ee55d1c8f71b774a9b2b734a04669139c20753b27a2a3a/types_pyyaml-6.0.12.20260724-py3-none-any.whl", hash = "sha256:d57db930a4b2efbc57cf430ec8882765d246929432fa253092f383902329a453", size = 20312, upload-time = "2026-07-24T04:58:42.486Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"