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"