diff --git a/pyproject.toml b/pyproject.toml index ae04ce5..3b5f131 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-client/pyproject.toml b/pystapi-client/pyproject.toml index 51b5a18..eab191a 100644 --- a/pystapi-client/pyproject.toml +++ b/pystapi-client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pystapi-client" -version = "0.0.1" +version = "0.0.2" description = "Python library for searching Satellite Tasking API (STAPI) APIs." readme = "README.md" authors = [ diff --git a/pystapi-client/src/pystapi_client/client.py b/pystapi-client/src/pystapi_client/client.py index d38c452..c41743a 100644 --- a/pystapi-client/src/pystapi_client/client.py +++ b/pystapi-client/src/pystapi_client/client.py @@ -14,10 +14,10 @@ Link, Opportunity, OpportunityCollection, - OpportunityPayload, + OpportunityRequest, Order, OrderCollection, - OrderPayload, + OrderRequest, Product, ProductsCollection, ) @@ -257,13 +257,53 @@ def has_conformance(self, conformance_class: ConformanceClasses | str) -> bool: return any(re.match(conformance_class.pattern, uri) for uri in self.get_conforms_to()) - def _supports_opportunities(self) -> bool: - """Check if the API supports opportunities""" - return self.has_conformance(ConformanceClasses.OPPORTUNITIES) + def _product_has_conformance( + self, + product: str | Product, + conformance_class: ConformanceClasses, + ) -> bool: + """Check whether a Product advertises the given conformance class. + + Opportunity capability classes are advertised per-Product (in the + Product's own ``conformsTo``, also served at + ``/products/{id}/conformance``), not in the root landing page. + + Args: + product: A Product ID or an already-fetched + :class:`~stapi_pydantic.Product`. If an ID is given the Product + is fetched from the API. + conformance_class: The conformance class to check for. + + Return: + Whether the Product conforms to the given class. + """ + if isinstance(product, str): + product = self.get_product(product) + return any(re.match(conformance_class.pattern, uri) for uri in product.conformsTo) + + def product_supports_opportunities(self, product: str | Product) -> bool: + """Check if a Product supports synchronous opportunity search. + + Args: + product: A Product ID or an already-fetched + :class:`~stapi_pydantic.Product`. + + Return: + Whether the Product supports synchronous opportunity search. + """ + return self._product_has_conformance(product, ConformanceClasses.OPPORTUNITIES) + + def product_supports_async_opportunities(self, product: str | Product) -> bool: + """Check if a Product supports asynchronous opportunity search. - def _supports_async_opportunities(self) -> bool: - """Check if the API supports asynchronous opportunities""" - return self.has_conformance(ConformanceClasses.ASYNC_OPPORTUNITIES) + Args: + product: A Product ID or an already-fetched + :class:`~stapi_pydantic.Product`. + + Return: + Whether the Product supports asynchronous opportunity search. + """ + return self._product_has_conformance(product, ConformanceClasses.ASYNC_OPPORTUNITIES) def get_products(self, limit: int | None = None) -> Iterator[Product]: """Get all products from this STAPI API @@ -316,14 +356,16 @@ def get_product_opportunities( """ product_opportunities_endpoint = self._get_products_href(product_id, subpath="opportunities") - opportunity_parameters = OpportunityPayload.model_validate( + opportunity_parameters = OpportunityRequest.model_validate( { - "datetime": ( - datetime.fromisoformat(date_range[0]), - datetime.fromisoformat(date_range[1]), - ), - "geometry": geometry, - "filter": cql2_filter, + "search_parameters": { + "datetime": ( + datetime.fromisoformat(date_range[0]), + datetime.fromisoformat(date_range[1]), + ), + "geometry": geometry, + "filter": cql2_filter, + }, "limit": limit, } ) @@ -348,7 +390,7 @@ def get_product_opportunities( for opportunity_collection in product_opportunities_json: yield from OpportunityCollection.model_validate(opportunity_collection).features - def create_product_order(self, product_id: str, order_parameters: OrderPayload) -> Order: # type: ignore[type-arg] + def create_product_order(self, product_id: str, order_parameters: OrderRequest) -> Order: # type: ignore[type-arg] # TODO Update return type after the pydantic model generic type is fixed """Create an order for a product diff --git a/pystapi-client/src/pystapi_client/conformance.py b/pystapi-client/src/pystapi_client/conformance.py index f936173..24d3ff0 100644 --- a/pystapi-client/src/pystapi_client/conformance.py +++ b/pystapi-client/src/pystapi_client/conformance.py @@ -6,9 +6,14 @@ class ConformanceClasses(Enum): """Enumeration class for Conformance Classes""" # defined conformance classes regexes + # API-level classes (advertised in the root landing page / `/conformance`) CORE = "/core" + ORDER_STATUSES = "/order-statuses" + SEARCHES_OPPORTUNITY = "/searches-opportunity" + SEARCHES_OPPORTUNITY_STATUSES = "/searches-opportunity-statuses" + # Product-level classes (advertised in a Product's own `conformsTo`) OPPORTUNITIES = "/opportunities" - ASYNC_OPPORTUNITIES = "/async-opportunities" + ASYNC_OPPORTUNITIES = "/opportunities-async" @classmethod def get_by_name(cls, name: str) -> "ConformanceClasses": @@ -29,4 +34,4 @@ def valid_uri(self) -> str: @property def pattern(self) -> re.Pattern[str]: - return re.compile(rf"{re.escape('https://stapi.example.com/v')}(.*){re.escape(self.value)}") + return re.compile(rf"{re.escape('https://stapi.example.com/v')}[^/]+{re.escape(self.value)}\Z") diff --git a/pystapi-client/tests/conftest.py b/pystapi-client/tests/conftest.py index d3c57e1..9609c21 100644 --- a/pystapi-client/tests/conftest.py +++ b/pystapi-client/tests/conftest.py @@ -51,4 +51,7 @@ def mock_products_response(request: Request) -> Response: respx_mock.get("/products").mock(side_effect=mock_products_response) respx_mock.get("/products", params={"limit": 1}).mock(side_effect=mock_products_response) + for product in products["products"]: + respx_mock.get(f"/products/{product['id']}").return_value = Response(200, json=product) + yield respx_mock diff --git a/pystapi-client/tests/fixtures/landing_page.json b/pystapi-client/tests/fixtures/landing_page.json index 5c4a27e..edc1c80 100644 --- a/pystapi-client/tests/fixtures/landing_page.json +++ b/pystapi-client/tests/fixtures/landing_page.json @@ -3,9 +3,10 @@ "title": "A simple STAPI Example", "description": "This API demonstrated the landing page for a SpatioTemporal Asset Tasking API", "conformsTo": [ - "https://stapi.example.com/v0.1.0/core", - "https://geojson.org/schema/Point.json", - "https://geojson.org/schema/Polygon.json" + "https://stapi.example.com/v0.2.0/core", + "https://stapi.example.com/v0.2.0/order-statuses", + "https://stapi.example.com/v0.2.0/searches-opportunity", + "https://stapi.example.com/v0.2.0/searches-opportunity-statuses" ], "links": [ { diff --git a/pystapi-client/tests/fixtures/products.json b/pystapi-client/tests/fixtures/products.json index 5f216b0..e9fb083 100644 --- a/pystapi-client/tests/fixtures/products.json +++ b/pystapi-client/tests/fixtures/products.json @@ -1,8 +1,18 @@ { + "stapi_type": "ProductCollection", + "stapi_version": "0.2.0", "products": [ { "type": "Collection", + "stapi_type": "Product", + "stapi_version": "0.2.0", "id": "multispectral", + "conformsTo": [ + "https://stapi.example.com/v0.2.0/opportunities", + "https://stapi.example.com/v0.2.0/opportunities-async", + "https://geojson.org/schema/Point.json", + "https://geojson.org/schema/Polygon.json" + ], "title": "Multispectral", "description": "Full color EO image", "keywords": [ @@ -103,7 +113,13 @@ }, { "type": "Collection", + "stapi_type": "Product", + "stapi_version": "0.2.0", "id": "spotlight", + "conformsTo": [ + "https://geojson.org/schema/Point.json", + "https://geojson.org/schema/Polygon.json" + ], "title": "Spotlight", "description": "SAR Spotlight frame", "keywords": [ diff --git a/pystapi-client/tests/test_client.py b/pystapi-client/tests/test_client.py index ea39e5f..c0370da 100644 --- a/pystapi-client/tests/test_client.py +++ b/pystapi-client/tests/test_client.py @@ -1,4 +1,5 @@ from pystapi_client.client import Client +from pystapi_client.conformance import ConformanceClasses from respx import MockRouter from stapi_pydantic import Link @@ -23,3 +24,90 @@ def test_pagination(api: MockRouter) -> None: products_link = Link(href="http://stapi.test/products", method="GET", body={"limit": 1}, rel="") for products_collection in client.stapi_io.get_pages(products_link, "products"): assert len(products_collection["products"]) == 1 + + +def test_async_opportunities_uri_matches_reference_server() -> None: + server_advertised = "https://stapi.example.com/v0.2.0/opportunities-async" + assert ConformanceClasses.ASYNC_OPPORTUNITIES.pattern.match(server_advertised) + + +def test_sync_opportunities_uri_does_not_match_async_uri() -> None: + async_uri = "https://stapi.example.com/v0.2.0/opportunities-async" + assert not ConformanceClasses.OPPORTUNITIES.pattern.match(async_uri) + assert ConformanceClasses.OPPORTUNITIES.pattern.match("https://stapi.example.com/v0.2.0/opportunities") + + +# --- Item 1: version pattern is a single path segment, anchored with \Z --- + + +def test_version_pattern_matches_single_version_segment() -> None: + pattern = ConformanceClasses.OPPORTUNITIES.pattern + assert pattern.match("https://stapi.example.com/v0.2.0/opportunities") + + +def test_version_pattern_rejects_extra_path_segments() -> None: + pattern = ConformanceClasses.OPPORTUNITIES.pattern + assert not pattern.match("https://stapi.example.com/v0.2.0/foo/opportunities") + + +def test_version_pattern_rejects_empty_version() -> None: + pattern = ConformanceClasses.OPPORTUNITIES.pattern + assert not pattern.match("https://stapi.example.com/v/opportunities") + + +def test_version_pattern_rejects_trailing_newline() -> None: + pattern = ConformanceClasses.OPPORTUNITIES.pattern + assert not pattern.match("https://stapi.example.com/v0.2.0/opportunities\n") + + +# --- Item 2: API-level extension conformance classes exist in the enum --- + + +def test_api_level_extension_classes_exist_and_match() -> None: + order_statuses = ConformanceClasses.get_by_name("ORDER_STATUSES") + searches_opportunity = ConformanceClasses.get_by_name("SEARCHES_OPPORTUNITY") + searches_opportunity_statuses = ConformanceClasses.get_by_name("SEARCHES_OPPORTUNITY_STATUSES") + + assert order_statuses.pattern.match("https://stapi.example.com/v0.2.0/order-statuses") + assert searches_opportunity.pattern.match("https://stapi.example.com/v0.2.0/searches-opportunity") + assert searches_opportunity_statuses.pattern.match("https://stapi.example.com/v0.2.0/searches-opportunity-statuses") + + +def test_searches_opportunity_does_not_match_statuses_uri() -> None: + searches_opportunity = ConformanceClasses.get_by_name("SEARCHES_OPPORTUNITY") + assert not searches_opportunity.pattern.match("https://stapi.example.com/v0.2.0/searches-opportunity-statuses") + + +# --- Item 3 / 4: product-scoped opportunity capability checks --- + + +def test_supports_opportunities_reads_product_conformance(api: MockRouter) -> None: + client = Client.open(url="http://stapi.test") + assert client.product_supports_opportunities("multispectral") is True + + +def test_supports_async_opportunities_reads_product_conformance(api: MockRouter) -> None: + client = Client.open(url="http://stapi.test") + assert client.product_supports_async_opportunities("multispectral") is True + + +def test_product_without_opportunities_returns_false(api: MockRouter) -> None: + client = Client.open(url="http://stapi.test") + assert client.product_supports_opportunities("spotlight") is False + assert client.product_supports_async_opportunities("spotlight") is False + + +def test_opportunity_support_does_not_depend_on_root_conformance(api: MockRouter) -> None: + client = Client.open(url="http://stapi.test") + # Root conformsTo must not advertise the product-level opportunity classes. + assert not client.has_conformance(ConformanceClasses.OPPORTUNITIES) + assert not client.has_conformance(ConformanceClasses.ASYNC_OPPORTUNITIES) + # Yet the product does support opportunities per its own conformsTo. + assert client.product_supports_opportunities("multispectral") is True + + +def test_root_advertises_api_level_extension_classes(api: MockRouter) -> None: + client = Client.open(url="http://stapi.test") + assert client.has_conformance(ConformanceClasses.CORE) + assert client.has_conformance("ORDER_STATUSES") + assert client.has_conformance("SEARCHES_OPPORTUNITY") 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..04a4c56 --- /dev/null +++ b/pystapi-schema-generator/pyproject.toml @@ -0,0 +1,30 @@ +[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 = "Christian Wygoda", email = "christian.wygoda@wygoda.net" }, + { name = "Phil Varner", email = "phil@philvarner.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..00a7d55 --- /dev/null +++ b/pystapi-schema-generator/src/pystapi_schema_generator/application.py @@ -0,0 +1,474 @@ +"""A minimal, generic FastAPI app dedicated to OpenAPI spec export. + +This package realizes the thin package structure proposed in stapi-fastapi +PR #68, against the real ``stapi_fastapi`` routers (no forked routers). + +This module builds a STAPI application wired with a single, generically-named +example product and stub backends for every optional capability. The backends +are never invoked during schema export (``app.openapi()`` only introspects +routes and models), so they simply raise ``NotImplementedError``. + +Using the base model classes (``OrderParameters``, ``Queryables``, +``OpportunityProperties``) keeps the generated component schema names generic +(e.g. ``OrderRequest_OrderParameters_``) rather than leaking test fixture +names. It deliberately imports nothing from ``tests/`` so the exported spec +describes the API generically. +""" + +import json +import sys +from collections import Counter +from copy import deepcopy +from typing import Any, NoReturn + +import yaml +from fastapi import FastAPI +from fastapi.routing import APIRoute +from stapi_fastapi.conformance import API, PRODUCT +from stapi_fastapi.models.product import Product +from stapi_fastapi.routers.root_router import RootRouter +from stapi_pydantic import ( + STAPI_VERSION, + OpportunityProperties, + OrderParameters, + Provider, + ProviderRole, + Queryables, +) + +#: The concrete product id used to instantiate the single reference product. +#: It appears in route names and concrete paths and is scrubbed from the +#: published document during post-processing so nothing leaks the fixture id. +_PRODUCT_ID = "example" + +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"} + +_SPEC_DOCS = "https://github.com/stapi-spec/stapi-spec/blob/main/docs" + +#: API-level conformance classes the reference app advertises. Imported from +#: the stapi-fastapi conformance constants (never 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, + conformsTo=[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), + ) + 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 _operation_id_map(app: FastAPI) -> dict[tuple[str, str], str]: + """Build a ``(concrete path, HTTP method) -> clean operationId`` map. + + The reference routers name their routes with structured, colon-delimited + names (e.g. ``root:example:create-order``). We derive generic, deterministic + operationIds from those names by dropping the ``root`` prefix and the + concrete product id, so the published document reads ``create_order`` + instead of the FastAPI default ``root_example_create_order_..._post``. + Product-scoped ids that would otherwise collide with a root-level id are + disambiguated with a ``product_`` prefix (e.g. ``product_conformance``). + """ + + def base_id(name: str) -> tuple[str, bool]: + segments = [s for s in name.split(":") if s != "root"] + product_scoped = _PRODUCT_ID in segments + segments = [s for s in segments if s != _PRODUCT_ID] + base = "_".join(segments).replace("-", "_") or "root" + return base, product_scoped + + routes = [r for r in app.routes if isinstance(r, APIRoute)] + base_ids = [base_id(r.name) for r in routes] + counts = Counter(base for base, _ in base_ids) + + mapping: dict[tuple[str, str], str] = {} + for route, (base, product_scoped) in zip(routes, base_ids): + operation_id = f"product_{base}" if product_scoped and counts[base] > 1 else base + for method in route.methods or set(): + if method in ("HEAD", "OPTIONS"): + continue + mapping[(route.path, method.lower())] = operation_id + return mapping + + +def _apply_operation_ids(openapi: dict[str, Any], id_map: dict[tuple[str, str], str]) -> None: + """Overwrite FastAPI's generated operationIds with the clean generic ones.""" + for path, path_item in openapi["paths"].items(): + for method, operation in path_item.items(): + if method not in _HTTP_METHODS or not isinstance(operation, dict): + continue + operation_id = id_map.get((path, method)) + if operation_id is not None: + operation["operationId"] = operation_id + + +def _clean_response_titles(openapi: dict[str, Any]) -> None: + """Strip FastAPI's mangled auto-titles that leak the concrete product id. + + Inline response schemas (e.g. the queryables / order-parameters ``object`` + responses) get titles like ``Response Root Example Get Queryables ...``. + They carry no useful information, so drop them entirely rather than trying + to preserve a generic form. + """ + for path_item in openapi["paths"].values(): + for operation in path_item.values(): + if not isinstance(operation, dict): + continue + for response in operation.get("responses", {}).values(): + for media in response.get("content", {}).values(): + schema = media.get("schema") + if isinstance(schema, dict) and "$ref" not in schema: + title = schema.get("title", "") + if _PRODUCT_ID in title.lower().split(): + schema.pop("title", None) + + +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, e.g. + ``Order_OrderStatus_`` or the ``OpportunityCollection_Annotated_Union_...`` + monster. The schema's ``title`` carries the readable generic form + (``Order[OrderStatus]``), so the clean base is the identifier before the + first ``[``. FastAPI's own ``-Input`` / ``-Output`` suffixes (validation vs + serialization schemas) are preserved. + """ + for suffix in ("-Input", "-Output"): + if name.endswith(suffix): + base = _base_schema_name(name[: -len(suffix)], {**schema, "title": schema.get("title", "")}) + return base + suffix + title: str = schema.get("title", "") or "" + if "[" in title: + return title.split("[", 1)[0] + return name + + +def _rewrite_refs(node: Any, rename: dict[str, str]) -> Any: + """Recursively rewrite ``$ref`` schema names according to ``rename``.""" + if isinstance(node, dict): + ref = node.get("$ref") + if isinstance(ref, str) and ref.startswith("#/components/schemas/"): + old = ref[len("#/components/schemas/") :] + if old in rename: + node["$ref"] = "#/components/schemas/" + rename[old] + 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 _collect_schema_refs(node: Any, into: set[str]) -> None: + """Collect every referenced ``#/components/schemas/`` into ``into``.""" + if isinstance(node, dict): + ref = node.get("$ref") + if isinstance(ref, str) and ref.startswith("#/components/schemas/"): + into.add(ref[len("#/components/schemas/") :]) + for value in node.values(): + _collect_schema_refs(value, into) + elif isinstance(node, list): + for item in node: + _collect_schema_refs(item, into) + + +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.""" + signatures: dict[str, str] = {} + dedup: dict[str, str] = {} + for name in sorted(schemas): + 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 _prune_orphan_schemas(openapi: dict[str, Any]) -> None: + """Drop component schemas that nothing references. + + In particular this removes the empty ``BaseModel`` component that Pydantic + emits for the ``type[BaseModel]`` annotation behind the queryables / + order-parameters responses (those responses already carry an inline + ``{"type": "object"}`` JSON-Schema via ``WithJsonSchema``, so the component + is a dangling orphan). + """ + schemas: dict[str, Any] = openapi["components"]["schemas"] + while True: + referenced: set[str] = set() + _collect_schema_refs(openapi["paths"], referenced) + _collect_schema_refs(schemas, referenced) + orphans = [name for name in schemas if name not in referenced] + if not orphans: + break + for name in orphans: + del schemas[name] + + +def _add_conformance_examples(openapi: dict[str, Any]) -> None: + """Attach the concrete v0.2.0 conformance URIs as response examples. + + The reference document otherwise contains no conformance URIs anywhere. + We surface the URIs the reference app actually advertises on both the + ``GET /conformance`` response and the landing page ``conformsTo``. + """ + conformance_uris = list(_ADVERTISED_CONFORMANCE) + + conformance_op = openapi["paths"].get("/conformance", {}).get("get") + if conformance_op is not None: + for media in conformance_op.get("responses", {}).get("200", {}).get("content", {}).values(): + media.setdefault("example", {"conformsTo": conformance_uris}) + + root_response = openapi["components"]["schemas"].get("RootResponse") + if root_response is not None: + conforms = root_response.get("properties", {}).get("conformsTo") + if isinstance(conforms, dict): + conforms.setdefault("example", conformance_uris) + + +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)) + 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.""" + app = create_reference_app() + id_map = _operation_id_map(app) + openapi = app.openapi() + + _apply_operation_ids(openapi, id_map) + _clean_response_titles(openapi) + _clean_schema_names(openapi) + _prune_orphan_schemas(openapi) + _add_conformance_examples(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..0dcc63c --- /dev/null +++ b/pystapi-schema-generator/tests/test_application.py @@ -0,0 +1,286 @@ +import os +import subprocess +import sys +from typing import Any + +from pystapi_schema_generator.application import _ADVERTISED_CONFORMANCE, 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/{opportunity_collection_id}", + "/orders", + "/orders/{order_id}", + "/orders/{order_id}/statuses", + "/searches/opportunities", + "/searches/opportunities/{search_record_id}", + "/searches/opportunities/{search_record_id}/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", + "GeometryCollection-Input", + "GeometryCollection-Output", + "HTTPValidationError", + "LineString", + "Link", + "MultiLineString", + "MultiPoint", + "MultiPolygon", + "Opportunity", + "OpportunityCollection", + "OpportunityProperties", + "OpportunityRequest-Input", + "OpportunityRequest-Output", + "OpportunitySearchRecord", + "OpportunitySearchRecordCollection", + "OpportunitySearchStatus", + "OpportunitySearchStatusCode", + "OpportunitySearchStatusCollection", + "Order", + "OrderCollection", + "OrderParameters", + "OrderProperties", + "OrderRequest", + "OrderStatus", + "OrderStatus-2", + "OrderStatusCode", + "OrderStatusCollection", + "Point", + "Polygon", + "Position2D", + "Position3D", + "Product", + "ProductsCollection", + "Provider", + "ProviderRole", + "RootResponse", + "SearchParameters-Input", + "SearchParameters-Output", + "StoredOrderRequest", + "ValidationError", +} + +_HTTP_METHODS = {"get", "put", "post", "delete", "patch"} + + +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_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: + 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): + ref = node.get("$ref") + if isinstance(ref, str) and ref.startswith("#/components/schemas/"): + referenced.add(ref[len("#/components/schemas/") :]) + 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_conformance_response_lists_v020_uris() -> None: + schema = export_openapi() + content = schema["paths"]["/conformance"]["get"]["responses"]["200"]["content"] + example = content["application/json"]["example"] + assert API.core in example["conformsTo"] + assert all(uri in example["conformsTo"] for uri in _ADVERTISED_CONFORMANCE) + + +def test_landing_page_conformsto_has_example() -> None: + root = export_openapi()["components"]["schemas"]["RootResponse"] + example = root["properties"]["conformsTo"].get("example") + assert example is not None + assert API.core in example + + +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={"PYTHONHASHSEED": seed, "PATH": os.environ.get("PATH", "")}, + ) + return result.stdout + + assert run("0") == run("123456789") diff --git a/scripts/export-openapi b/scripts/export-openapi new file mode 100755 index 0000000..753893e --- /dev/null +++ b/scripts/export-openapi @@ -0,0 +1,16 @@ +#!/usr/bin/env -S uv run python + +"""Export the STAPI OpenAPI document as YAML. + +This is a thin shim that delegates to the packaged reference app export tool +(``pystapi_schema_generator.application.main``), which is also installed as +the ``pystapi-schema-generator`` console script. + +Usage: + + scripts/export-openapi > /path/to/spec/openapi.yaml +""" + +from pystapi_schema_generator.application import main + +main() 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/stapi-fastapi/pyproject.toml b/stapi-fastapi/pyproject.toml index d97a9e8..54e87f2 100644 --- a/stapi-fastapi/pyproject.toml +++ b/stapi-fastapi/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stapi-fastapi" -version = "0.8.0" +version = "0.9.0" description = "Sensor Tasking API (STAPI) with FastAPI" authors = [ { name = "Christian Wygoda", email = "christian.wygoda@wygoda.net" }, diff --git a/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py b/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py index aa74510..8698d64 100644 --- a/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py +++ b/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py @@ -9,16 +9,16 @@ from stapi_pydantic import ( Opportunity, OpportunityCollection, - OpportunityPayload, + OpportunityRequest, OpportunitySearchRecord, Order, - OrderPayload, + OrderRequest, ) from stapi_fastapi.routers.product_router import ProductRouter SearchOpportunities = Callable[ - [ProductRouter, OpportunityPayload, str | None, int, Request], + [ProductRouter, OpportunityRequest, str | None, int, Request], Coroutine[Any, Any, ResultE[tuple[list[Opportunity], Maybe[str]]]], # type: ignore ] """ @@ -27,7 +27,7 @@ Args: product_router (ProductRouter): The product router. - search (OpportunityPayload): The search parameters. + search (OpportunityRequest): The search parameters. next (str | None): A pagination token. limit (int): The maximum number of opportunities to return in a page. request (Request): FastAPI's Request object. @@ -47,7 +47,7 @@ """ SearchOpportunitiesAsync = Callable[ - [ProductRouter, OpportunityPayload, Request], + [ProductRouter, OpportunityRequest, Request], Coroutine[Any, Any, ResultE[OpportunitySearchRecord]], ] """ @@ -56,7 +56,7 @@ Args: product_router (ProductRouter): The product router. - search (OpportunityPayload): The search parameters. + search (OpportunityRequest): The search parameters. request (Request): FastAPI's Request object. Returns: @@ -90,13 +90,13 @@ - Returning returns.result.Failure[Exception] will result in a 500. """ -CreateOrder = Callable[[ProductRouter, OrderPayload, Request], Coroutine[Any, Any, ResultE[Order]]] # type: ignore +CreateOrder = Callable[[ProductRouter, OrderRequest, Request], Coroutine[Any, Any, ResultE[Order]]] # type: ignore """ Type alias for an async function that creates a new order. Args: product_router (ProductRouter): The product router. - payload (OrderPayload): The order payload. + payload (OrderRequest): The order payload. request (Request): FastAPI's Request object. Returns: diff --git a/stapi-fastapi/src/stapi_fastapi/errors.py b/stapi-fastapi/src/stapi_fastapi/errors.py index 1ae870c..6cfae92 100644 --- a/stapi-fastapi/src/stapi_fastapi/errors.py +++ b/stapi-fastapi/src/stapi_fastapi/errors.py @@ -9,7 +9,7 @@ class StapiError(HTTPException): class QueryablesError(StapiError): def __init__(self, detail: Any) -> None: - super().__init__(status.HTTP_422_UNPROCESSABLE_ENTITY, detail) + super().__init__(status.HTTP_400_BAD_REQUEST, detail) class NotFoundError(StapiError): diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index 430ae00..8206383 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -21,12 +21,13 @@ JsonSchemaModel, Link, OpportunityCollection, - OpportunityPayload, - OpportunitySearchRecord, + OpportunityRequest, Order, - OrderPayload, + OrderRequest, OrderStatus, Prefer, + SearchParameters, + cql2_property_names, ) from stapi_pydantic import ( Product as ProductPydantic, @@ -79,7 +80,6 @@ def build_conformances(product: Product, root_router: RootRouter) -> list[str]: conformances.add(PRODUCT_CONFORMACES.opportunities) if product.supports_async_opportunity_search and root_router.supports_async_opportunity_search: - conformances.add(PRODUCT_CONFORMACES.opportunities) conformances.add(PRODUCT_CONFORMACES.opportunities_async) return list(conformances) @@ -144,13 +144,13 @@ def __init__( # noqa # the annotation on every `ProductRouter` instance's `create_order`, not just # this one's. async def _create_order( - payload: OrderPayload, # type: ignore + payload: OrderRequest, # type: ignore request: Request, response: Response, ) -> Order[OrderStatus]: return await self.create_order(payload, request, response) - _create_order.__annotations__["payload"] = OrderPayload[ + _create_order.__annotations__["payload"] = OrderRequest[ self.product.order_parameters # type: ignore ] @@ -161,13 +161,44 @@ async def _create_order( methods=["POST"], response_class=GeoJSONResponse, status_code=status.HTTP_201_CREATED, + responses={ + 201: { + "headers": { + "Location": { + "description": "URL of the created Order.", + "schema": {"type": "string", "format": "uri"}, + }, + }, + }, + }, summary="Create an order for the product", tags=["Products"], ) - if product.supports_opportunity_search or ( + supports_async = ( self.product.supports_async_opportunity_search and self.root_router.supports_async_opportunity_search - ): + ) + if product.supports_opportunity_search or supports_async: + # The async search returns a 201 with an OpportunitySearchRecord, which + # is not GeoJSON, so it is documented as application/json rather than the + # route's default application/geo+json (used by the sync 200 collection). + # It is only documented when this product actually supports async search, + # both because that is the only case it can occur and because + # OpportunitySearchRecord is only registered in the OpenAPI components + # schemas when the async search-record endpoints exist. + extra_responses: dict[int | str, dict[str, Any]] = {} + if supports_async: + extra_responses[201] = { + "description": "Created (async opportunity search record)", + "content": {TYPE_JSON: {"schema": {"$ref": "#/components/schemas/OpportunitySearchRecord"}}}, + "headers": { + "Location": { + "description": "URL of the created Opportunity Search Record.", + "schema": {"type": "string", "format": "uri"}, + }, + }, + } + self.add_api_route( path="/opportunities", endpoint=self.search_opportunities, @@ -179,12 +210,7 @@ async def _create_order( Geometry, self.product.opportunity_properties, # type: ignore ], - responses={ - 201: { - "model": OpportunitySearchRecord, - "content": {TYPE_JSON: {}}, - } - }, + responses=extra_responses, summary="Search Opportunities for the product", tags=["Products"], ) @@ -233,7 +259,7 @@ def get_product(self, request: Request) -> ProductPydantic: async def search_opportunities( self, - search: OpportunityPayload, + search: OpportunityRequest, request: Request, response: Response, prefer: Prefer | None = Depends(get_prefer), @@ -264,11 +290,12 @@ async def search_opportunities( async def search_opportunities_sync( self, - search: OpportunityPayload, + search: OpportunityRequest, request: Request, response: Response, prefer: Prefer | None, ) -> OpportunityCollection: # type: ignore + self.validate_required_queryables(search.search_parameters) links: list[Link] = [] match await self.product.search_opportunities( self, @@ -298,20 +325,24 @@ async def search_opportunities_sync( case x: raise AssertionError(f"Expected code to be unreachable {x}") - if prefer is Prefer.wait and self.root_router.supports_async_opportunity_search: + # Per spec, whenever the client sent a Prefer header the server must + # respond with Preference-Applied indicating the mode actually applied. + # This branch always applies the sync ("wait") mode. + if prefer is not None: response.headers["Preference-Applied"] = "wait" return OpportunityCollection(features=features, links=links) async def search_opportunities_async( self, - search: OpportunityPayload, + search: OpportunityRequest, request: Request, prefer: Prefer | None, ) -> JSONResponse: + self.validate_required_queryables(search.search_parameters) match await self.product.search_opportunities_async(self, search, request): case Success(search_record): - search_record.links.append(self.root_router.opportunity_search_record_self_link(search_record, request)) + search_record.links.extend(self.root_router.opportunity_search_record_links(search_record, request)) headers = {} headers["Location"] = str( self.root_router.generate_opportunity_search_record_href(request, search_record.id) @@ -355,10 +386,17 @@ def get_product_order_parameters(self) -> JsonSchemaModel: """ return self.product.order_parameters - async def create_order(self, payload: OrderPayload, request: Request, response: Response) -> Order: # type: ignore + def validate_required_queryables(self, search_parameters: SearchParameters) -> None: + required = set(self.product.queryables.model_json_schema().get("required", [])) + missing = required - cql2_property_names(search_parameters.filter) + if missing: + raise QueryablesError(f"filter must include predicates for required queryables: {sorted(missing)}") + + async def create_order(self, payload: OrderRequest, request: Request, response: Response) -> Order: # type: ignore """ Create a new order. """ + self.validate_required_queryables(payload.search_parameters) match await self.product.create_order( self, payload, @@ -383,7 +421,7 @@ async def create_order(self, payload: OrderPayload, request: Request, response: case x: raise AssertionError(f"Expected code to be unreachable {x}") - def order_link(self, request: Request, opp_req: OpportunityPayload) -> Link: + def order_link(self, request: Request, opp_req: OpportunityRequest) -> Link: return Link( href=self.url_for(request, f"{self.root_router.name}:{self.product.id}:{CREATE_ORDER}"), rel="create-order", @@ -392,7 +430,7 @@ def order_link(self, request: Request, opp_req: OpportunityPayload) -> Link: body=opp_req.search_body(), ) - def pagination_link(self, request: Request, opp_req: OpportunityPayload, pagination_token: str) -> Link: + def pagination_link(self, request: Request, opp_req: OpportunityRequest, pagination_token: str) -> Link: body = opp_req.body() body["next"] = pagination_token return Link( diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index c33abc1..9164079 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -10,12 +10,12 @@ Conformance, Link, OpportunitySearchRecord, - OpportunitySearchRecords, - OpportunitySearchStatus, + OpportunitySearchRecordCollection, + OpportunitySearchStatusCollection, Order, OrderCollection, OrderStatus, - OrderStatuses, + OrderStatusCollection, ProductsCollection, RootResponse, ) @@ -160,16 +160,16 @@ def __init__( tags=["Opportunities"], ) - if self.__get_opportunity_search_record_statuses is not None: - _conformances.add(API_CONFORMANCE.searches_opportunity_statuses) - self.add_api_route( - "/searches/opportunities/{search_record_id}/statuses", - self.get_opportunity_search_record_statuses, - methods=["GET"], - name=f"{self.name}:{GET_OPPORTUNITY_SEARCH_RECORD_STATUSES}", - summary="Get an Opportunity Search Record statuses by ID", - tags=["Opportunities"], - ) + if self.__get_opportunity_search_record_statuses is not None: + _conformances.add(API_CONFORMANCE.searches_opportunity_statuses) + self.add_api_route( + "/searches/opportunities/{search_record_id}/statuses", + self.get_opportunity_search_record_statuses, + methods=["GET"], + name=f"{self.name}:{GET_OPPORTUNITY_SEARCH_RECORD_STATUSES}", + summary="Get an Opportunity Search Record statuses by ID", + tags=["Opportunities"], + ) self.conformances = list(_conformances) @@ -200,7 +200,7 @@ def get_root(self, request: Request) -> RootResponse: if self.supports_async_opportunity_search: links.append( json_link( - "opportunity-search-records", + "search-records", self.url_for(request, f"{self.name}:{LIST_OPPORTUNITY_SEARCH_RECORDS}"), ), ) @@ -306,7 +306,7 @@ async def get_order_statuses( request: Request, next: str | None = None, limit: int = 10, - ) -> OrderStatuses: # type: ignore + ) -> OrderStatusCollection: # type: ignore links: list[Link] = [] match await self._get_order_statuses(order_id, next, limit, request): case Success(Some((statuses, maybe_pagination_token))): @@ -335,7 +335,7 @@ async def get_order_statuses( ) case _: raise AssertionError("Expected code to be unreachable") - return OrderStatuses(statuses=statuses, links=links) + return OrderStatusCollection(statuses=statuses, links=links) def add_product(self, product: Product, *args: Any, **kwargs: Any) -> None: # Give the include a prefix from the product router @@ -374,12 +374,12 @@ def pagination_link(self, request: Request, name: str, pagination_token: str, li async def get_opportunity_search_records( self, request: Request, next: str | None = None, limit: int = 10 - ) -> OpportunitySearchRecords: + ) -> OpportunitySearchRecordCollection: links: list[Link] = [] match await self._get_opportunity_search_records(next, limit, request): case Success((records, maybe_pagination_token)): for record in records: - record.links.append(self.opportunity_search_record_self_link(record, request)) + record.links.extend(self.opportunity_search_record_links(record, request)) match maybe_pagination_token: case Some(next_): links.append( @@ -402,7 +402,7 @@ async def get_opportunity_search_records( ) case _: raise AssertionError("Expected code to be unreachable") - return OpportunitySearchRecords(search_records=records, links=links) + return OpportunitySearchRecordCollection(records=records, links=links) async def get_opportunity_search_record(self, search_record_id: str, request: Request) -> OpportunitySearchRecord: """ @@ -410,7 +410,7 @@ async def get_opportunity_search_record(self, search_record_id: str, request: Re """ match await self._get_opportunity_search_record(search_record_id, request): case Success(Some(search_record)): - search_record.links.append(self.opportunity_search_record_self_link(search_record, request)) + search_record.links.extend(self.opportunity_search_record_links(search_record, request)) return search_record # type: ignore case Success(Maybe.empty): raise NotFoundError("Opportunity Search Record not found") @@ -429,13 +429,21 @@ async def get_opportunity_search_record(self, search_record_id: str, request: Re async def get_opportunity_search_record_statuses( self, search_record_id: str, request: Request - ) -> list[OpportunitySearchStatus]: + ) -> OpportunitySearchStatusCollection: """ Get the Opportunity Search Record statuses with `search_record_id`. """ match await self._get_opportunity_search_record_statuses(search_record_id, request): case Success(Some(search_record_statuses)): - return search_record_statuses # type: ignore + self_link = json_link( + "self", + self.url_for( + request, + f"{self.name}:{GET_OPPORTUNITY_SEARCH_RECORD_STATUSES}", + search_record_id=search_record_id, + ), + ) + return OpportunitySearchStatusCollection(statuses=search_record_statuses, links=[self_link]) case Success(Maybe.empty): raise NotFoundError("Opportunity Search Record not found") case Failure(e): @@ -463,6 +471,31 @@ def opportunity_search_record_self_link( ) -> Link: return json_link("self", self.generate_opportunity_search_record_href(request, opportunity_search_record.id)) + def generate_opportunity_search_record_statuses_href(self, request: Request, search_record_id: str) -> URL: + return self.url_for( + request, + f"{self.name}:{GET_OPPORTUNITY_SEARCH_RECORD_STATUSES}", + search_record_id=search_record_id, + ) + + def opportunity_search_record_links( + self, opportunity_search_record: OpportunitySearchRecord, request: Request + ) -> list[Link]: + """Links added to every search record response. + + Always includes a `self` link. When the search-record-statuses endpoint + is registered, a `monitor` link to that endpoint is also included. + """ + links = [self.opportunity_search_record_self_link(opportunity_search_record, request)] + if self.supports_opportunity_search_record_statuses: + links.append( + json_link( + "monitor", + self.generate_opportunity_search_record_statuses_href(request, opportunity_search_record.id), + ) + ) + return links + @property def _get_order_statuses(self) -> GetOrderStatuses: # type: ignore if not self.__get_order_statuses: @@ -490,3 +523,12 @@ def _get_opportunity_search_record_statuses(self) -> GetOpportunitySearchRecordS @property def supports_async_opportunity_search(self) -> bool: return self.__get_opportunity_search_records is not None and self.__get_opportunity_search_record is not None + + @property + def supports_opportunity_search_record_statuses(self) -> bool: + """Whether the search-record-statuses endpoint is registered. + + Gated on async opportunity search support, since the statuses endpoint's + parent search-record endpoints only exist when async search is supported. + """ + return self.supports_async_opportunity_search and self.__get_opportunity_search_record_statuses is not None diff --git a/stapi-fastapi/tests/application.py b/stapi-fastapi/tests/application.py index 6a34a5d..3d035d5 100644 --- a/stapi-fastapi/tests/application.py +++ b/stapi-fastapi/tests/application.py @@ -8,6 +8,7 @@ from tests.backends import ( mock_get_opportunity_search_record, + mock_get_opportunity_search_record_statuses, mock_get_opportunity_search_records, mock_get_order, mock_get_order_statuses, @@ -35,6 +36,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: get_order_statuses=mock_get_order_statuses, get_opportunity_search_records=mock_get_opportunity_search_records, get_opportunity_search_record=mock_get_opportunity_search_record, + get_opportunity_search_record_statuses=mock_get_opportunity_search_record_statuses, conformances=[API.core], ) root_router.add_product(product_test_spotlight_sync_opportunity) diff --git a/stapi-fastapi/tests/backends.py b/stapi-fastapi/tests/backends.py index f902fb1..dcfdc48 100644 --- a/stapi-fastapi/tests/backends.py +++ b/stapi-fastapi/tests/backends.py @@ -8,16 +8,16 @@ from stapi_pydantic import ( Opportunity, OpportunityCollection, - OpportunityPayload, + OpportunityRequest, OpportunitySearchRecord, OpportunitySearchStatus, OpportunitySearchStatusCode, Order, - OrderPayload, OrderProperties, - OrderSearchParameters, + OrderRequest, OrderStatus, OrderStatusCode, + StoredOrderRequest, ) @@ -80,7 +80,7 @@ async def mock_get_order_statuses( return Failure(e) -async def mock_create_order(product_router: ProductRouter, payload: OrderPayload, request: Request) -> ResultE[Order]: +async def mock_create_order(product_router: ProductRouter, payload: OrderRequest, request: Request) -> ResultE[Order]: """ Create a new order. """ @@ -91,17 +91,15 @@ async def mock_create_order(product_router: ProductRouter, payload: OrderPayload ) order = Order( id=str(uuid4()), - geometry=payload.geometry, + geometry=payload.search_parameters.geometry, properties=OrderProperties( product_id=product_router.product.id, created=datetime.now(UTC), status=status, - search_parameters=OrderSearchParameters( - geometry=payload.geometry, - datetime=payload.datetime, - filter=payload.filter, + order_request=StoredOrderRequest( + search_parameters=payload.search_parameters, + order_parameters=payload.order_parameters.model_dump(), ), - order_parameters=payload.order_parameters.model_dump(), opportunity_properties={ "datetime": "2024-01-29T12:00:00Z/2024-01-30T12:00:00Z", "off_nadir": 10, @@ -119,7 +117,7 @@ async def mock_create_order(product_router: ProductRouter, payload: OrderPayload async def mock_search_opportunities( product_router: ProductRouter, - search: OpportunityPayload, + search: OpportunityRequest, next: str | None, limit: int, request: Request, @@ -130,7 +128,13 @@ async def mock_search_opportunities( if next: start = int(next) end = start + limit - opportunities = [o.model_copy(update=search.model_dump()) for o in request.state._opportunities[start:end]] + # Reflect the searched geometry into the returned opportunities. (The prior + # `model_copy(update=search.model_dump())` was a no-op: the request's dump + # keys are search_parameters/next/limit, none of which are Opportunity fields.) + opportunities = [ + o.model_copy(update={"geometry": search.search_parameters.geometry}) + for o in request.state._opportunities[start:end] + ] if end > 0 and end < len(request.state._opportunities): return Success((opportunities, Some(str(end)))) return Success((opportunities, Nothing)) @@ -140,7 +144,7 @@ async def mock_search_opportunities( async def mock_search_opportunities_async( product_router: ProductRouter, - search: OpportunityPayload, + search: OpportunityRequest, request: Request, ) -> ResultE[OpportunitySearchRecord]: try: @@ -151,7 +155,7 @@ async def mock_search_opportunities_async( search_record = OpportunitySearchRecord( id=str(uuid4()), product_id=product_router.product.id, - opportunity_request=search, + request=search, status=received_status, links=[], ) diff --git a/stapi-fastapi/tests/conftest.py b/stapi-fastapi/tests/conftest.py index b0be83c..ebce204 100644 --- a/stapi-fastapi/tests/conftest.py +++ b/stapi-fastapi/tests/conftest.py @@ -7,7 +7,7 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient -from stapi_fastapi.conformance import API, PRODUCT +from stapi_fastapi.conformance import API from stapi_fastapi.models.product import ( Product, ) @@ -79,7 +79,6 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: ) for mock_product in mock_products: - mock_product.conformsTo = [PRODUCT.opportunities, PRODUCT.opportunities_async, PRODUCT.geojson_point] root_router.add_product(mock_product) app = FastAPI(lifespan=lifespan) @@ -121,7 +120,6 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: ) for mock_product in mock_products: - mock_product.conformsTo = [PRODUCT.opportunities, PRODUCT.opportunities_async, PRODUCT.geojson_point] root_router.add_product(mock_product) app = FastAPI(lifespan=lifespan) @@ -176,17 +174,19 @@ def opportunity_search(limit) -> dict[str, Any]: end_string = rfc3339_strftime(end, format) return { - "geometry": { - "type": "Point", - "coordinates": [0, 0], - }, - "datetime": f"{start_string}/{end_string}", - "filter": { - "op": "and", - "args": [ - {"op": ">", "args": [{"property": "off_nadir"}, 0]}, - {"op": "<", "args": [{"property": "off_nadir"}, 45]}, - ], + "search_parameters": { + "geometry": { + "type": "Point", + "coordinates": [0, 0], + }, + "datetime": f"{start_string}/{end_string}", + "filter": { + "op": "and", + "args": [ + {"op": ">", "args": [{"property": "off_nadir"}, 0]}, + {"op": "<", "args": [{"property": "off_nadir"}, 45]}, + ], + }, }, "limit": limit, } diff --git a/stapi-fastapi/tests/shared.py b/stapi-fastapi/tests/shared.py index 7be0c1d..252005f 100644 --- a/stapi-fastapi/tests/shared.py +++ b/stapi-fastapi/tests/shared.py @@ -157,7 +157,7 @@ class MyOrderParameters(OrderParameters): queryables=MyProductQueryables, opportunity_properties=MyOpportunityProperties, order_parameters=MyOrderParameters, - conformsTo=[PRODUCT.geojson_point, PRODUCT.opportunities], + conformsTo=[PRODUCT.geojson_point], ) @@ -176,7 +176,7 @@ class MyOrderParameters(OrderParameters): queryables=MyProductQueryables, opportunity_properties=MyOpportunityProperties, order_parameters=MyOrderParameters, - conformsTo=[PRODUCT.geojson_point, PRODUCT.opportunities_async], + conformsTo=[PRODUCT.geojson_point], ) product_test_spotlight_sync_async_opportunity = Product( @@ -194,7 +194,7 @@ class MyOrderParameters(OrderParameters): queryables=MyProductQueryables, opportunity_properties=MyOpportunityProperties, order_parameters=MyOrderParameters, - conformsTo=[PRODUCT.geojson_point, PRODUCT.opportunities, PRODUCT.opportunities_async], + conformsTo=[PRODUCT.geojson_point], ) product_test_satellite_provider_sync_opportunity = Product( @@ -212,7 +212,7 @@ class MyOrderParameters(OrderParameters): queryables=MyProductQueryables, opportunity_properties=MyOpportunityProperties, order_parameters=MyOrderParameters, - conformsTo=[PRODUCT.geojson_point, PRODUCT.opportunities], + conformsTo=[PRODUCT.geojson_point], ) diff --git a/stapi-fastapi/tests/test_opportunity.py b/stapi-fastapi/tests/test_opportunity.py index cffe525..8147eb9 100644 --- a/stapi-fastapi/tests/test_opportunity.py +++ b/stapi-fastapi/tests/test_opportunity.py @@ -55,3 +55,21 @@ def test_search_opportunities_pagination( expected_returns=expected_returns, body=opportunity_search, ) + + +def test_search_opportunities_rejects_missing_required_queryable_predicate( + stapi_client: TestClient, +) -> None: + # test-spotlight's queryables model (MyProductQueryables) requires `off_nadir`; + # omitting a filter predicate for it should be rejected before hitting the backend. + product_id = "test-spotlight" + response = stapi_client.post( + f"/products/{product_id}/opportunities", + json={ + "search_parameters": { + "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", + "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, + }, + }, + ) + assert response.status_code == 400 diff --git a/stapi-fastapi/tests/test_opportunity_async.py b/stapi-fastapi/tests/test_opportunity_async.py index ea34eb1..83c74bc 100644 --- a/stapi-fastapi/tests/test_opportunity_async.py +++ b/stapi-fastapi/tests/test_opportunity_async.py @@ -1,11 +1,14 @@ -from collections.abc import Callable +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager from datetime import UTC, datetime, timedelta from typing import Any from uuid import uuid4 import pytest -from fastapi import status +from fastapi import FastAPI, status from fastapi.testclient import TestClient +from stapi_fastapi.conformance import API, PRODUCT +from stapi_fastapi.routers.root_router import RootRouter from stapi_pydantic import ( Link, OpportunityCollection, @@ -14,7 +17,17 @@ OpportunitySearchStatusCode, ) +from .backends import ( + mock_get_opportunity_search_record, + mock_get_opportunity_search_record_statuses, + mock_get_opportunity_search_records, + mock_get_order, + mock_get_order_statuses, + mock_get_orders, +) from .shared import ( + InMemoryOpportunityDB, + InMemoryOrderDB, create_mock_opportunity, find_link, pagination_tester, @@ -26,6 +39,164 @@ from .test_datetime_interval import rfc3339_strftime +def _build_client(base_url: str = "http://stapiserver", **root_router_kwargs: Any) -> TestClient: + """Build a test client with an app whose root router is configured explicitly.""" + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: + yield { + "_orders_db": InMemoryOrderDB(), + "_opportunities_db": InMemoryOpportunityDB(), + "_opportunities": [create_mock_opportunity()], + } + + root_router = RootRouter( + get_orders=mock_get_orders, + get_order=mock_get_order, + get_order_statuses=mock_get_order_statuses, + conformances=[API.core], + **root_router_kwargs, + ) + root_router.add_product(product_test_spotlight_async_opportunity) + + app = FastAPI(lifespan=lifespan) + app.include_router(root_router, prefix="") + return TestClient(app, base_url=base_url) + + +def _build_async_client(with_statuses: bool, base_url: str = "http://stapiserver") -> TestClient: + """Async-capable client, optionally with the statuses backend wired.""" + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: + yield { + "_orders_db": InMemoryOrderDB(), + "_opportunities_db": InMemoryOpportunityDB(), + "_opportunities": [create_mock_opportunity()], + } + + kwargs: dict[str, Any] = {} + if with_statuses: + kwargs["get_opportunity_search_record_statuses"] = mock_get_opportunity_search_record_statuses + + root_router = RootRouter( + get_orders=mock_get_orders, + get_order=mock_get_order, + get_order_statuses=mock_get_order_statuses, + get_opportunity_search_records=mock_get_opportunity_search_records, + get_opportunity_search_record=mock_get_opportunity_search_record, + conformances=[API.core], + **kwargs, + ) + root_router.add_product(product_test_spotlight_async_opportunity) + + app = FastAPI(lifespan=lifespan) + app.include_router(root_router, prefix="") + return TestClient(app, base_url=base_url) + + +def test_monitor_link_present_on_search_records( + opportunity_search: dict[str, Any], + url_for: Callable[[str], str], +) -> None: + product_id = "test-spotlight" + statuses_href = None + with _build_async_client(with_statuses=True) as client: + # 201 create + create_res = client.post(f"/products/{product_id}/opportunities", json=opportunity_search) + assert create_res.status_code == 201 + create_body = create_res.json() + record_id = create_body["id"] + statuses_href = url_for(f"/searches/opportunities/{record_id}/statuses") + + monitor = find_link(create_body["links"], "monitor") + assert monitor + assert monitor["href"] == statuses_href + + # GET single record + get_res = client.get(f"/searches/opportunities/{record_id}") + assert get_res.status_code == 200 + get_monitor = find_link(get_res.json()["links"], "monitor") + assert get_monitor + assert get_monitor["href"] == statuses_href + + # GET record list + list_res = client.get("/searches/opportunities") + assert list_res.status_code == 200 + record = next(r for r in list_res.json()["records"] if r["id"] == record_id) + list_monitor = find_link(record["links"], "monitor") + assert list_monitor + assert list_monitor["href"] == statuses_href + + +def test_monitor_link_absent_without_statuses_backend( + opportunity_search: dict[str, Any], +) -> None: + product_id = "test-spotlight" + with _build_async_client(with_statuses=False) as client: + create_res = client.post(f"/products/{product_id}/opportunities", json=opportunity_search) + assert create_res.status_code == 201 + record_id = create_res.json()["id"] + assert find_link(create_res.json()["links"], "monitor") is None + + get_res = client.get(f"/searches/opportunities/{record_id}") + assert find_link(get_res.json()["links"], "monitor") is None + + list_res = client.get("/searches/opportunities") + record = next(r for r in list_res.json()["records"] if r["id"] == record_id) + assert find_link(record["links"], "monitor") is None + + +def test_openapi_async_search_201_metadata() -> None: + from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON + + with _build_async_client(with_statuses=True) as client: + spec = client.app.openapi() + responses = spec["paths"]["/products/test-spotlight/opportunities"]["post"]["responses"] + + # 201 documents the OpportunitySearchRecord as application/json (not geo+json) + r201 = responses["201"] + assert set(r201["content"].keys()) == {TYPE_JSON} + assert r201["content"][TYPE_JSON]["schema"]["$ref"].endswith("/OpportunitySearchRecord") + # Location header documented + assert "Location" in r201["headers"] + + # the sync 200 opportunity collection stays application/geo+json + assert set(responses["200"]["content"].keys()) == {TYPE_GEOJSON} + + +def test_openapi_create_order_201_location_header() -> None: + from stapi_fastapi.constants import TYPE_GEOJSON + + with _build_async_client(with_statuses=True) as client: + spec = client.app.openapi() + r201 = spec["paths"]["/products/test-spotlight/orders"]["post"]["responses"]["201"] + assert "Location" in r201["headers"] + # Order is GeoJSON, content stays geo+json + assert set(r201["content"].keys()) == {TYPE_GEOJSON} + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_statuses_unknown_id_returns_404( + stapi_client_async_opportunity: TestClient, +) -> None: + res = stapi_client_async_opportunity.get("/searches/opportunities/does-not-exist/statuses") + assert res.status_code == status.HTTP_404_NOT_FOUND + + +def test_statuses_endpoint_gated_on_async_support() -> None: + # A statuses backend is supplied but async search record backends are NOT, + # so the statuses route must not be registered and its conformance absent. + with _build_client( + get_opportunity_search_record_statuses=mock_get_opportunity_search_record_statuses, + ) as client: + res = client.get("/searches/opportunities/anything/statuses") + assert res.status_code == status.HTTP_404_NOT_FOUND + + conformance = client.get("/conformance").json()["conformsTo"] + assert API.searches_opportunity_statuses not in conformance + + @pytest.mark.mock_products([product_test_spotlight]) def test_no_opportunity_search_advertised(stapi_client: TestClient) -> None: product_id = "test-spotlight" @@ -38,7 +209,7 @@ def test_no_opportunity_search_advertised(stapi_client: TestClient) -> None: # the `searches/opportunities` link should not be advertised on the root root_response = stapi_client.get("/") root_body = root_response.json() - assert find_link(root_body["links"], "opportunity-search-records") is None + assert find_link(root_body["links"], "search-records") is None @pytest.mark.mock_products([product_test_spotlight_sync_opportunity]) @@ -53,7 +224,7 @@ def test_only_sync_search_advertised(stapi_client: TestClient) -> None: # the `searches/opportunities` link should not be advertised on the root root_response = stapi_client.get("/") root_body = root_response.json() - assert find_link(root_body["links"], "opportunity-search-records") is None + assert find_link(root_body["links"], "search-records") is None # test async search offered @@ -75,7 +246,38 @@ def test_async_search_advertised(stapi_client_async_opportunity: TestClient) -> # the `searches/opportunities` link should be advertised on the root root_response = stapi_client_async_opportunity.get("/") root_body = root_response.json() - assert find_link(root_body["links"], "opportunity-search-records") + assert find_link(root_body["links"], "search-records") + + +@pytest.mark.mock_products([product_test_spotlight_sync_opportunity]) +def test_sync_only_product_conformance(stapi_client: TestClient) -> None: + product_id = "test-spotlight" + res = stapi_client.get(f"/products/{product_id}/conformance") + assert res.status_code == status.HTTP_200_OK + conforms_to = res.json()["conformsTo"] + assert PRODUCT.opportunities in conforms_to + assert PRODUCT.opportunities_async not in conforms_to + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_async_only_product_conformance(stapi_client_async_opportunity: TestClient) -> None: + product_id = "test-spotlight" + res = stapi_client_async_opportunity.get(f"/products/{product_id}/conformance") + assert res.status_code == status.HTTP_200_OK + conforms_to = res.json()["conformsTo"] + # async capability does not imply sync class + assert PRODUCT.opportunities_async in conforms_to + assert PRODUCT.opportunities not in conforms_to + + +@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) +def test_sync_async_product_conformance(stapi_client_async_opportunity: TestClient) -> None: + product_id = "test-spotlight" + res = stapi_client_async_opportunity.get(f"/products/{product_id}/conformance") + assert res.status_code == status.HTTP_200_OK + conforms_to = res.json()["conformsTo"] + assert PRODUCT.opportunities in conforms_to + assert PRODUCT.opportunities_async in conforms_to @pytest.mark.mock_products([product_test_spotlight_async_opportunity]) @@ -147,6 +349,54 @@ def test_prefer_header( pytest.fail("response is not an opportunity search record") +@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) +def test_preference_applied_match_wait( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + # prefer=wait honored by a sync+async product -> wait applied + url = "/products/test-spotlight/opportunities" + res = stapi_client_async_opportunity.post(url, json=opportunity_search, headers={"Prefer": "wait"}) + assert res.status_code == 200 + assert res.headers["Preference-Applied"] == "wait" + + +@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) +def test_preference_applied_match_respond_async( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + # prefer=respond-async honored by a sync+async product -> respond-async applied + url = "/products/test-spotlight/opportunities" + res = stapi_client_async_opportunity.post(url, json=opportunity_search, headers={"Prefer": "respond-async"}) + assert res.status_code == 201 + assert res.headers["Preference-Applied"] == "respond-async" + + +@pytest.mark.mock_products([product_test_spotlight_sync_opportunity]) +def test_preference_applied_mismatch_respond_async_on_sync_only( + stapi_client: TestClient, + opportunity_search: dict[str, Any], +) -> None: + # respond-async requested but product only supports sync -> wait applied + url = "/products/test-spotlight/opportunities" + res = stapi_client.post(url, json=opportunity_search, headers={"Prefer": "respond-async"}) + assert res.status_code == 200 + assert res.headers["Preference-Applied"] == "wait" + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_preference_applied_mismatch_wait_on_async_only( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> None: + # wait requested but product only supports async -> respond-async applied + url = "/products/test-spotlight/opportunities" + res = stapi_client_async_opportunity.post(url, json=opportunity_search, headers={"Prefer": "wait"}) + assert res.status_code == 201 + assert res.headers["Preference-Applied"] == "respond-async" + + @pytest.mark.mock_products([product_test_spotlight_async_opportunity]) def test_async_search_record_retrieval( stapi_client_async_opportunity: TestClient, @@ -170,7 +420,7 @@ def test_async_search_record_retrieval( records_response = stapi_client_async_opportunity.get("/searches/opportunities") assert records_response.status_code == 200 records_response_body = records_response.json() - assert search_record_id in [x["id"] for x in records_response_body["search_records"]] + assert search_record_id in [x["id"] for x in records_response_body["records"]] @pytest.mark.mock_products([product_test_spotlight_async_opportunity]) @@ -196,7 +446,7 @@ def test_async_opportunity_search_to_completion( Link( rel="create-order", href=url_for(f"/products/{product_id}/orders"), - body=search_record.opportunity_request.model_dump(), + body=search_record.request.model_dump(), method="POST", ) ) @@ -234,7 +484,9 @@ def test_async_opportunity_search_to_completion( url = f"/searches/opportunities/{search_record.id}/statuses" retrieved_statuses_response = stapi_client_async_opportunity.get(url) assert retrieved_statuses_response.status_code == 200 - retrieved_statuses = [OpportunitySearchStatus(**d) for d in retrieved_statuses_response.json()] + retrieved_statuses_body = retrieved_statuses_response.json() + assert retrieved_statuses_body["stapi_type"] == "OpportunitySearchStatusCollection" + retrieved_statuses = [OpportunitySearchStatus(**d) for d in retrieved_statuses_body["statuses"]] assert len(retrieved_statuses) >= 1 assert retrieved_statuses[-1].status_code == OpportunitySearchStatusCode.completed @@ -293,17 +545,19 @@ def setup_search_record_pagination( end_string = rfc3339_strftime(end, format) opportunity_request = { - "geometry": { - "type": "Point", - "coordinates": [0, 0], - }, - "datetime": f"{start_string}/{end_string}", - "filter": { - "op": "and", - "args": [ - {"op": ">", "args": [{"property": "off_nadir"}, 0]}, - {"op": "<", "args": [{"property": "off_nadir"}, 45]}, - ], + "search_parameters": { + "geometry": { + "type": "Point", + "coordinates": [0, 0], + }, + "datetime": f"{start_string}/{end_string}", + "filter": { + "op": "and", + "args": [ + {"op": ">", "args": [{"property": "off_nadir"}, 0]}, + {"op": "<", "args": [{"property": "off_nadir"}, 45]}, + ], + }, }, } @@ -334,6 +588,25 @@ def test_get_search_records_pagination( url="/searches/opportunities", method="GET", limit=limit, - target="search_records", + target="records", expected_returns=expected_returns, ) + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_async_search_rejects_missing_required_queryable_predicate( + stapi_client_async_opportunity: TestClient, +) -> None: + # test-spotlight's queryables model (MyProductQueryables) requires `off_nadir`; + # omitting a filter predicate for it should be rejected before hitting the backend. + product_id = "test-spotlight" + response = stapi_client_async_opportunity.post( + f"/products/{product_id}/opportunities", + json={ + "search_parameters": { + "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", + "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, + }, + }, + ) + assert response.status_code == 400 diff --git a/stapi-fastapi/tests/test_order.py b/stapi-fastapi/tests/test_order.py index d33291c..e57ec92 100644 --- a/stapi-fastapi/tests/test_order.py +++ b/stapi-fastapi/tests/test_order.py @@ -6,10 +6,18 @@ from geojson_pydantic import Point from geojson_pydantic.types import Position2D from httpx import Response -from stapi_pydantic import Order, OrderPayload, OrderStatus, OrderStatusCode +from stapi_pydantic import STAPI_VERSION, Order, OrderRequest, OrderStatus, OrderStatusCode, SearchParameters from .shared import MyOrderParameters, find_link, pagination_tester +REQUIRED_QUERYABLE_FILTER = { + "op": "and", + "args": [ + {"op": ">", "args": [{"property": "off_nadir"}, 0]}, + {"op": "<", "args": [{"property": "off_nadir"}, 45]}, + ], +} + NOW = datetime.now(UTC) START = NOW END = START + timedelta(days=5) @@ -19,11 +27,18 @@ def test_empty_order(stapi_client: TestClient): res = stapi_client.get("/orders") assert res.status_code == status.HTTP_200_OK assert res.headers["Content-Type"] == "application/geo+json" - assert res.json() == {"type": "FeatureCollection", "features": [], "links": [], "numberMatched": 314} + assert res.json() == { + "type": "FeatureCollection", + "stapi_type": "OrderCollection", + "stapi_version": STAPI_VERSION, + "features": [], + "links": [], + "numberMatched": 314, + } @pytest.fixture -def create_order_payloads() -> list[OrderPayload]: +def create_order_payloads() -> list[OrderRequest]: datetimes = [ ("2024-10-09T18:55:33Z", "2024-10-12T18:55:33Z"), ("2024-10-15T18:55:33Z", "2024-10-18T18:55:33Z"), @@ -31,13 +46,15 @@ def create_order_payloads() -> list[OrderPayload]: ] payloads = [] for start, end in datetimes: - payload = OrderPayload( - geometry=Point(type="Point", coordinates=Position2D(longitude=14.4, latitude=56.5)), - datetime=( - datetime.fromisoformat(start), - datetime.fromisoformat(end), + payload = OrderRequest( + search_parameters=SearchParameters( + geometry=Point(type="Point", coordinates=Position2D(longitude=14.4, latitude=56.5)), + datetime=( + datetime.fromisoformat(start), + datetime.fromisoformat(end), + ), + filter=REQUIRED_QUERYABLE_FILTER, ), - filter=None, order_parameters=MyOrderParameters(s3_path="s3://my-bucket"), ) payloads.append(payload) @@ -48,7 +65,7 @@ def create_order_payloads() -> list[OrderPayload]: def new_order_response( product_id: str, stapi_client: TestClient, - create_order_payloads: list[OrderPayload], + create_order_payloads: list[OrderRequest], ) -> Response: res = stapi_client.post( f"products/{product_id}/orders", @@ -102,18 +119,22 @@ def get_order_response(stapi_client: TestClient, new_order_response: Response) - @pytest.mark.parametrize("product_id", ["test-spotlight"]) def test_get_order_properties(get_order_response: Response, create_order_payloads) -> None: order = get_order_response.json() + payload_search_parameters = create_order_payloads[0].search_parameters assert order["geometry"] == { "type": "Point", - "coordinates": list(create_order_payloads[0].geometry.coordinates), + "coordinates": list(payload_search_parameters.geometry.coordinates), } - assert order["properties"]["search_parameters"]["geometry"] == { + assert order["properties"]["order_request"]["search_parameters"]["geometry"] == { "type": "Point", - "coordinates": list(create_order_payloads[0].geometry.coordinates), + "coordinates": list(payload_search_parameters.geometry.coordinates), } - assert order["properties"]["search_parameters"]["datetime"] == create_order_payloads[0].model_dump()["datetime"] + assert ( + order["properties"]["order_request"]["search_parameters"]["datetime"] + == payload_search_parameters.model_dump(mode="json")["datetime"] + ) @pytest.mark.parametrize("product_id", ["test-spotlight"]) @@ -227,3 +248,34 @@ def test_get_order_statuses_bad_token( order_id = "non_existing_order_id" res = stapi_client.get(f"/orders/{order_id}/statuses") assert res.status_code == status.HTTP_404_NOT_FOUND + + +def test_create_order_rejects_missing_required_queryable_predicate(stapi_client: TestClient) -> None: + # test-spotlight's queryables model (MyProductQueryables) requires `off_nadir`; + # omitting a filter predicate for it should be rejected before hitting the backend. + product_id = "test-spotlight" + response = stapi_client.post( + f"/products/{product_id}/orders", + json={ + "search_parameters": { + "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", + "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, + }, + "order_parameters": {"s3_path": "s3://my-bucket"}, + }, + ) + assert response.status_code == 400 + + +@pytest.mark.parametrize("product_id", ["test-spotlight"]) +def test_get_order_statuses_is_collection(get_order_response: Response, stapi_client: TestClient) -> None: + body = get_order_response.json() + link = find_link(body["links"], "monitor") + assert link is not None + + res = stapi_client.get(link["href"]) + assert res.status_code == status.HTTP_200_OK + + statuses_body = res.json() + assert statuses_body["stapi_type"] == "OrderStatusCollection" + assert "statuses" in statuses_body diff --git a/stapi-fastapi/tests/test_product.py b/stapi-fastapi/tests/test_product.py index 1fee345..325797b 100644 --- a/stapi-fastapi/tests/test_product.py +++ b/stapi-fastapi/tests/test_product.py @@ -15,7 +15,7 @@ def test_products_response(stapi_client: TestClient): data = res.json() - assert data["type"] == "ProductCollection" + assert data["stapi_type"] == "ProductCollection" assert isinstance(data["products"], list) diff --git a/stapi-pydantic/pyproject.toml b/stapi-pydantic/pyproject.toml index 8fc6e50..50f4c46 100644 --- a/stapi-pydantic/pyproject.toml +++ b/stapi-pydantic/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stapi-pydantic" -version = "0.1.0" +version = "0.2.0" description = "Pydantic models for Satellite Tasking API (STAPI) Specification" readme = "README.md" authors = [ @@ -8,7 +8,7 @@ authors = [ { name = "Pete Gadomski", email = "pete.gadomski@gmail.com" }, ] requires-python = ">=3.11" -dependencies = ["pydantic>=2.12", "cql2>=0.3.6", "geojson-pydantic>=1.2.0"] +dependencies = ["pydantic>=2.12", "cql2>=0.3.6", "geojson-pydantic>=1.2.0", "typing-extensions>=4.12"] [dependency-groups] dev = [ diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index 44ecbd0..283ebfc 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -1,57 +1,63 @@ from .conformance import Conformance -from .datetime_interval import DatetimeInterval -from .filter import CQL2Filter +from .constants import STAPI_VERSION +from .datetime_interval import DatetimeInterval, OpenDatetimeInterval +from .filter import CQL2Filter, cql2_property_names from .json_schema_model import JsonSchemaModel from .opportunity import ( Opportunity, OpportunityCollection, - OpportunityPayload, OpportunityProperties, + OpportunityRequest, OpportunitySearchRecord, - OpportunitySearchRecords, + OpportunitySearchRecordCollection, OpportunitySearchStatus, OpportunitySearchStatusCode, + OpportunitySearchStatusCollection, Prefer, ) from .order import ( + BaseOrderParameters, Order, OrderCollection, OrderParameters, - OrderPayload, OrderProperties, - OrderSearchParameters, + OrderRequest, OrderStatus, OrderStatusCode, - OrderStatuses, + OrderStatusCollection, + StoredOrderRequest, ) from .product import Product, ProductsCollection, Provider, ProviderRole from .queryables import Queryables from .root import RootResponse +from .search_parameters import SearchParameters from .shared import Link __all__ = [ + "BaseOrderParameters", "Conformance", "CQL2Filter", "DatetimeInterval", "JsonSchemaModel", "Link", + "OpenDatetimeInterval", "Opportunity", "OpportunityCollection", - "OpportunityPayload", "OpportunityProperties", + "OpportunityRequest", "OpportunitySearchRecord", - "OpportunitySearchRecords", + "OpportunitySearchRecordCollection", "OpportunitySearchStatus", "OpportunitySearchStatusCode", + "OpportunitySearchStatusCollection", "Order", "OrderCollection", "OrderParameters", - "OrderPayload", "OrderProperties", - "OrderSearchParameters", + "OrderRequest", "OrderStatus", "OrderStatusCode", - "OrderStatuses", + "OrderStatusCollection", "Prefer", "Product", "ProductsCollection", @@ -59,4 +65,8 @@ "ProviderRole", "Queryables", "RootResponse", + "SearchParameters", + "StoredOrderRequest", + "STAPI_VERSION", + "cql2_property_names", ] diff --git a/stapi-pydantic/src/stapi_pydantic/conformance.py b/stapi-pydantic/src/stapi_pydantic/conformance.py index 2011b4f..65b0c9c 100644 --- a/stapi-pydantic/src/stapi_pydantic/conformance.py +++ b/stapi-pydantic/src/stapi_pydantic/conformance.py @@ -1,5 +1,7 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class Conformance(BaseModel): + model_config = ConfigDict(json_schema_serialization_defaults_required=True) + conforms_to: list[str] = Field(default_factory=list, serialization_alias="conformsTo") diff --git a/stapi-pydantic/src/stapi_pydantic/constants.py b/stapi-pydantic/src/stapi_pydantic/constants.py index 80915d1..97daddd 100644 --- a/stapi-pydantic/src/stapi_pydantic/constants.py +++ b/stapi-pydantic/src/stapi_pydantic/constants.py @@ -1,2 +1,2 @@ -STAPI_VERSION = "0.1.0" +STAPI_VERSION = "0.2.0" """The default STAPI version for this library.""" diff --git a/stapi-pydantic/src/stapi_pydantic/datetime_interval.py b/stapi-pydantic/src/stapi_pydantic/datetime_interval.py index ea31577..40c4fe5 100644 --- a/stapi-pydantic/src/stapi_pydantic/datetime_interval.py +++ b/stapi-pydantic/src/stapi_pydantic/datetime_interval.py @@ -10,6 +10,14 @@ WrapSerializer, ) +OPEN_END = ".." + + +def _parse_end(value: str) -> datetime | None: + if value in ("", OPEN_END): + return None + return datetime.fromisoformat(value) + def validate_before( value: str | tuple[datetime, datetime], @@ -20,12 +28,31 @@ def validate_before( return value +def validate_open_before( + value: str | tuple[datetime | None, datetime | None], +) -> tuple[datetime | None, datetime | None]: + if isinstance(value, str): + start, end = value.split("/", 1) + return (_parse_end(start), _parse_end(end)) + return value + + def validate_after(value: tuple[datetime, datetime]) -> tuple[datetime, datetime]: if value[1] < value[0]: raise ValueError("end before start") return value +def validate_open_after( + value: tuple[datetime | None, datetime | None], +) -> tuple[datetime | None, datetime | None]: + if value[0] is None and value[1] is None: + raise ValueError("only singly-open intervals are allowed") + if value[0] is not None and value[1] is not None: + validate_after((value[0], value[1])) + return value + + def serialize( value: tuple[datetime, datetime], serializer: Callable[[tuple[datetime, datetime]], tuple[str, str]], @@ -34,6 +61,16 @@ def serialize( return f"{value[0].isoformat()}/{value[1].isoformat()}" +def serialize_open( + value: tuple[datetime | None, datetime | None], + serializer: Callable[[tuple[datetime | None, datetime | None]], tuple[str, str]], +) -> str: + del serializer # unused + start = OPEN_END if value[0] is None else value[0].isoformat() + end = OPEN_END if value[1] is None else value[1].isoformat() + return f"{start}/{end}" + + DatetimeInterval = Annotated[ tuple[AwareDatetime, AwareDatetime], BeforeValidator(validate_before), @@ -41,3 +78,13 @@ def serialize( WrapSerializer(serialize, return_type=str), WithJsonSchema({"type": "string"}), ] + +# Interval that may be open (via ``..`` or an empty string) on at most one +# end, per the Search Parameters Object datetime definition. +OpenDatetimeInterval = Annotated[ + tuple[AwareDatetime | None, AwareDatetime | None], + BeforeValidator(validate_open_before), + AfterValidator(validate_open_after), + WrapSerializer(serialize_open, return_type=str), + WithJsonSchema({"type": "string"}), +] diff --git a/stapi-pydantic/src/stapi_pydantic/filter.py b/stapi-pydantic/src/stapi_pydantic/filter.py index 2064fa9..7ef424b 100644 --- a/stapi-pydantic/src/stapi_pydantic/filter.py +++ b/stapi-pydantic/src/stapi_pydantic/filter.py @@ -15,3 +15,22 @@ def validate(v: dict[str, Any]) -> dict[str, Any]: dict, BeforeValidator(validate), ] + + +def cql2_property_names(filter_: dict[str, Any] | None) -> set[str]: + """Collect all property names referenced in a CQL2 JSON expression.""" + names: set[str] = set() + + def walk(node: Any) -> None: + match node: + case {"property": str(name)}: + names.add(name) + case dict(): + for value in node.values(): + walk(value) + case list(): + for item in node: + walk(item) + + walk(filter_ or {}) + return names diff --git a/stapi-pydantic/src/stapi_pydantic/geometry.py b/stapi-pydantic/src/stapi_pydantic/geometry.py new file mode 100644 index 0000000..c760420 --- /dev/null +++ b/stapi-pydantic/src/stapi_pydantic/geometry.py @@ -0,0 +1,30 @@ +from typing import Any + +from geojson_pydantic.geometries import Geometry +from geojson_pydantic.types import BBox + + +def _all_coordinates(geometry: Geometry) -> list[list[float]]: + """Flatten any GeoJSON geometry's coordinates to a list of positions.""" + if geometry.type == "GeometryCollection": + return [c for g in geometry.geometries for c in _all_coordinates(g)] + + def flatten(coords: Any) -> list[list[float]]: + if coords and isinstance(coords[0], int | float): + return [list(coords)] + return [p for c in coords for p in flatten(c)] + + return flatten(geometry.coordinates) + + +def compute_geometry_bbox(geometry: Geometry) -> BBox: + """Compute an RFC 7946 bbox (2D or 3D) from a geometry's coordinates.""" + coords = _all_coordinates(geometry) + if not coords: + raise ValueError("cannot compute bbox: geometry has no coordinates") + lons = [c[0] for c in coords] + lats = [c[1] for c in coords] + if all(len(c) >= 3 for c in coords): + elevations = [c[2] for c in coords] + return (min(lons), min(lats), min(elevations), max(lons), max(lats), max(elevations)) + return (min(lons), min(lats), max(lons), max(lats)) diff --git a/stapi-pydantic/src/stapi_pydantic/opportunity.py b/stapi-pydantic/src/stapi_pydantic/opportunity.py index a20a9fc..413f296 100644 --- a/stapi-pydantic/src/stapi_pydantic/opportunity.py +++ b/stapi-pydantic/src/stapi_pydantic/opportunity.py @@ -1,13 +1,19 @@ +from __future__ import annotations + from enum import StrEnum -from typing import Any, Literal, TypeVar +from typing import Annotated, Any, Generic, Literal, TypeVar, cast from geojson_pydantic import Feature, FeatureCollection from geojson_pydantic.geometries import Geometry -from pydantic import AwareDatetime, BaseModel, ConfigDict, Field +from geojson_pydantic.types import BBox +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator +from typing_extensions import TypeVar as DefaultTypeVar +from .constants import STAPI_VERSION from .datetime_interval import DatetimeInterval -from .filter import CQL2Filter -from .shared import Link +from .geometry import compute_geometry_bbox +from .search_parameters import SearchParameters +from .shared import Link, NumberMatched # Copied and modified from https://github.com/stac-utils/stac-pydantic/blob/main/stac_pydantic/item.py#L11 @@ -17,10 +23,15 @@ class OpportunityProperties(BaseModel): model_config = ConfigDict(extra="allow") -class OpportunityPayload(BaseModel): - datetime: DatetimeInterval - geometry: Geometry - filter: CQL2Filter | None = None # type: ignore [type-arg] +class OpportunityRequest(BaseModel): + """STAPI Opportunity Request Object. + + Structured the same as the Order Request Object minus order_parameters, + so an Opportunity Request can be submitted unmodified as an Order Request. + See stapi-spec docs/spec/opportunity/README.md#opportunity-request-object. + """ + + search_parameters: SearchParameters next: str | None = None limit: int = 10 @@ -28,7 +39,7 @@ class OpportunityPayload(BaseModel): model_config = ConfigDict(strict=True) def search_body(self) -> dict[str, Any]: - return self.model_dump(mode="json", include={"datetime", "geometry", "filter"}) + return self.model_dump(mode="json", include={"search_parameters"}) def body(self) -> dict[str, Any]: return self.model_dump(mode="json") @@ -39,14 +50,39 @@ def body(self) -> dict[str, Any]: class Opportunity(Feature[G, P]): + model_config = ConfigDict(json_schema_serialization_defaults_required=True) + + # spec types opportunity id as a string (not int); exclude_if keeps a None + # id out of dumps and out of the serialization-required set. + id: str | None = Field(default=None, exclude_if=lambda v: v is None) type: Literal["Feature"] = "Feature" + stapi_type: Literal["Opportunity"] = "Opportunity" + stapi_version: str = STAPI_VERSION + geometry: G = Field(...) + # bbox is spec-REQUIRED and non-nullable; compute_bbox fills the default. + bbox: BBox = cast(BBox, None) + properties: P = Field(...) links: list[Link] = Field(default_factory=list) + @model_validator(mode="after") + def compute_bbox(self) -> Opportunity[G, P]: + if self.bbox is None: + self.bbox = compute_geometry_bbox(self.geometry) + return self + class OpportunityCollection(FeatureCollection[Opportunity[G, P]]): + model_config = ConfigDict(serialize_by_alias=True, json_schema_serialization_defaults_required=True) + type: Literal["FeatureCollection"] = "FeatureCollection" + stapi_type: Literal["OpportunityCollection"] = "OpportunityCollection" + stapi_version: str = STAPI_VERSION + # geojson-pydantic excludes bbox-when-None via a custom serializer schema + # gen can't see; override with a schema-visible exclude_if. + bbox: BBox | None = Field(default=None, exclude_if=lambda v: v is None) links: list[Link] = Field(default_factory=list) - id: str | None = None + id: str | None = Field(default=None, exclude_if=lambda v: v is None) + number_matched: NumberMatched = None class OpportunitySearchStatusCode(StrEnum): @@ -57,25 +93,57 @@ class OpportunitySearchStatusCode(StrEnum): completed = "completed" -class OpportunitySearchStatus(BaseModel): +# Known codes validate to the enum; anything else stays a plain string, since +# the spec allows providers to add statuses through extensions. +AnySearchStatusCode = Annotated[OpportunitySearchStatusCode | str, Field(union_mode="left_to_right")] + +SearchStatusCode = DefaultTypeVar("SearchStatusCode", bound=str, default=AnySearchStatusCode) + + +class OpportunitySearchStatus(BaseModel, Generic[SearchStatusCode]): + """A search record status; parameterize with a StrEnum + (``OpportunitySearchStatus[MyCodes]``) to constrain status_code to an + implementation-defined set.""" + + model_config = ConfigDict(json_schema_serialization_defaults_required=True) + timestamp: AwareDatetime - status_code: OpportunitySearchStatusCode + status_code: SearchStatusCode reason_code: str | None = None reason_text: str | None = None links: list[Link] = Field(default_factory=list) class OpportunitySearchRecord(BaseModel): + model_config = ConfigDict(json_schema_serialization_defaults_required=True) + id: str product_id: str - opportunity_request: OpportunityPayload + request: OpportunityRequest status: OpportunitySearchStatus + stapi_type: Literal["OpportunitySearchRecord"] = "OpportunitySearchRecord" + stapi_version: str = STAPI_VERSION links: list[Link] = Field(default_factory=list) -class OpportunitySearchRecords(BaseModel): - search_records: list[OpportunitySearchRecord] +class OpportunitySearchRecordCollection(BaseModel): + model_config = ConfigDict(serialize_by_alias=True, json_schema_serialization_defaults_required=True) + + stapi_type: Literal["OpportunitySearchRecordCollection"] = "OpportunitySearchRecordCollection" + stapi_version: str = STAPI_VERSION + records: list[OpportunitySearchRecord] + links: list[Link] = Field(default_factory=list) + number_matched: NumberMatched = None + + +class OpportunitySearchStatusCollection(BaseModel): + model_config = ConfigDict(serialize_by_alias=True, json_schema_serialization_defaults_required=True) + + stapi_type: Literal["OpportunitySearchStatusCollection"] = "OpportunitySearchStatusCollection" + stapi_version: str = STAPI_VERSION + statuses: list[OpportunitySearchStatus] links: list[Link] = Field(default_factory=list) + number_matched: NumberMatched = None class Prefer(StrEnum): diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index 159b341..9e0c9a7 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -3,10 +3,11 @@ import datetime from collections.abc import Iterator from enum import StrEnum -from typing import Any, Generic, Literal, TypeVar +from typing import Annotated, Any, Generic, Literal, TypeVar, cast from geojson_pydantic.base import _GeoJsonBase from geojson_pydantic.geometries import Geometry +from geojson_pydantic.types import BBox from pydantic import ( AwareDatetime, BaseModel, @@ -14,19 +15,34 @@ Field, StrictStr, field_validator, + model_validator, ) +from typing_extensions import TypeVar as DefaultTypeVar from .constants import STAPI_VERSION -from .datetime_interval import DatetimeInterval -from .filter import CQL2Filter +from .geometry import compute_geometry_bbox from .opportunity import OpportunityProperties -from .shared import Link +from .search_parameters import SearchParameters +from .shared import Link, NumberMatched Props = TypeVar("Props", bound=dict[str, Any] | BaseModel) Geom = TypeVar("Geom", bound=Geometry) -class OrderParameters(BaseModel): +class BaseOrderParameters(BaseModel): + """Minimum-expectations type for order parameters at rest. + + Permissive (extra="allow") so stored parameters from any product + round-trip; spec-standardized common order parameters, if any are ever + defined, get typed fields here. + """ + + model_config = ConfigDict(extra="allow") + + +class OrderParameters(BaseOrderParameters): + """Boundary base for product-specific order parameters (strict).""" + model_config = ConfigDict(extra="forbid") @@ -50,9 +66,19 @@ class OrderStatusCode(StrEnum): failed = "failed" -class OrderStatus(BaseModel): +# Known codes validate to the enum; anything else stays a plain string, since +# the spec allows providers to add statuses through extensions. +AnyOrderStatusCode = Annotated[OrderStatusCode | str, Field(union_mode="left_to_right")] + +StatusCode = DefaultTypeVar("StatusCode", bound=str, default=AnyOrderStatusCode) + + +class OrderStatus(BaseModel, Generic[StatusCode]): + """An order status; parameterize with a StrEnum (``OrderStatus[MyCodes]``) + to constrain status_code to an implementation-defined set.""" + timestamp: AwareDatetime - status_code: OrderStatusCode + status_code: StatusCode reason_code: str | None = None reason_text: str | None = None links: list[Link] = Field(default_factory=list) @@ -61,7 +87,7 @@ class OrderStatus(BaseModel): @classmethod def new( - cls, status_code: OrderStatusCode, reason_code: str | None = None, reason_text: str | None = None + cls, status_code: OrderStatusCode | str, reason_code: str | None = None, reason_text: str | None = None ) -> OrderStatus: """Creates a new order status with timestamp set to now in UTC.""" return OrderStatus( @@ -72,35 +98,48 @@ def new( ) -T = TypeVar("T", bound=OrderStatus) +T = TypeVar("T", bound=OrderStatus[Any]) + +class OrderStatusCollection(BaseModel, Generic[T]): + model_config = ConfigDict(serialize_by_alias=True, json_schema_serialization_defaults_required=True) -class OrderStatuses(BaseModel, Generic[T]): + stapi_type: Literal["OrderStatusCollection"] = "OrderStatusCollection" + stapi_version: str = STAPI_VERSION statuses: list[T] links: list[Link] = Field(default_factory=list) + number_matched: NumberMatched = None -class OrderSearchParameters(BaseModel): - datetime: DatetimeInterval - geometry: Geometry - # TODO: validate the CQL2 filter? - filter: CQL2Filter | None = None # type: ignore [type-arg] +class StoredOrderRequest(BaseModel): + """Stored form of an Order Request within Order properties. + + order_parameters is typed as BaseOrderParameters because a persisted + order can no longer be validated against a product's strict + OrderParameters model. + """ + + # extras in stored order requests (e.g. provider extension fields) must + # round-trip rather than being silently dropped. + model_config = ConfigDict(extra="allow", json_schema_serialization_defaults_required=True) + + search_parameters: SearchParameters + order_parameters: BaseOrderParameters = Field(default_factory=BaseOrderParameters) class OrderProperties(BaseModel, Generic[T]): + model_config = ConfigDict(extra="allow", json_schema_serialization_defaults_required=True) + product_id: str created: AwareDatetime status: T - - search_parameters: OrderSearchParameters - opportunity_properties: dict[str, Any] - order_parameters: dict[str, Any] - - model_config = ConfigDict(extra="allow") + order_request: StoredOrderRequest # derived from geojson_pydantic.Feature class Order(_GeoJsonBase, Generic[T]): + model_config = ConfigDict(json_schema_serialization_defaults_required=True) + # We need to enforce that orders have an id defined, as that is required to # retrieve them via the API id: StrictStr @@ -109,11 +148,15 @@ class Order(_GeoJsonBase, Generic[T]): stapi_version: str = STAPI_VERSION geometry: Geometry = Field(...) + # bbox is spec-REQUIRED; non-nullable annotation makes the schema + # non-nullable in both modes, the config makes it serialization-required, + # and the compute_bbox after-validator fills the computed default. + bbox: BBox = cast(BBox, None) properties: OrderProperties[T] = Field(...) links: list[Link] = Field(default_factory=list) - __geojson_exclude_if_none__ = {"bbox", "id"} + __geojson_exclude_if_none__ = {"id"} @field_validator("geometry", mode="before") def set_geometry(cls, geometry: Any) -> Any: @@ -123,15 +166,27 @@ def set_geometry(cls, geometry: Any) -> Any: return geometry + @model_validator(mode="after") + def compute_bbox(self) -> Order[T]: + if self.bbox is None: + self.bbox = compute_geometry_bbox(self.geometry) + return self + # derived from geojson_pydantic.FeatureCollection class OrderCollection(_GeoJsonBase, Generic[T]): + model_config = ConfigDict(serialize_by_alias=True, json_schema_serialization_defaults_required=True) + type: Literal["FeatureCollection"] = "FeatureCollection" + stapi_type: Literal["OrderCollection"] = "OrderCollection" + stapi_version: str = STAPI_VERSION + # geojson-pydantic excludes bbox-when-None via a custom serializer that + # schema gen can't see; override with a schema-visible exclude_if so the + # new config doesn't wrongly mark it serialization-required. + bbox: BBox | None = Field(default=None, exclude_if=lambda v: v is None) features: list[Order[T]] links: list[Link] = Field(default_factory=list) - number_matched: int | None = Field( - serialization_alias="numberMatched", default=None, exclude_if=lambda x: x is None - ) + number_matched: NumberMatched = None def __iter__(self) -> Iterator[Order[T]]: # type: ignore [override] """iterate over features""" @@ -146,12 +201,16 @@ def __getitem__(self, index: int) -> Order[T]: return self.features[index] -class OrderPayload(BaseModel, Generic[ORP]): - datetime: DatetimeInterval = Field(examples=["2018-02-12T00:00:00Z/2018-03-18T12:31:12Z"]) - geometry: Geometry - # TODO: validate the CQL2 filter? - filter: CQL2Filter | None = None # type: ignore [type-arg] +class OrderRequest(BaseModel, Generic[ORP]): + """STAPI Order Request Object. + + See stapi-spec docs/spec/order/README.md#order-request-object. An omitted + order_parameters is equivalent to an empty object; the value must validate + against the product's OrderParameters model, so products with required + parameters make this field effectively required. + """ - order_parameters: ORP + search_parameters: SearchParameters + order_parameters: ORP = Field(default_factory=dict, validate_default=True) model_config = ConfigDict(strict=True) diff --git a/stapi-pydantic/src/stapi_pydantic/product.py b/stapi-pydantic/src/stapi_pydantic/product.py index 54b946f..a9049b3 100644 --- a/stapi-pydantic/src/stapi_pydantic/product.py +++ b/stapi-pydantic/src/stapi_pydantic/product.py @@ -1,10 +1,10 @@ from enum import StrEnum from typing import Any, Literal, Self -from pydantic import AnyHttpUrl, BaseModel, Field +from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field from .constants import STAPI_VERSION -from .shared import Link +from .shared import Link, NumberMatched class ProviderRole(StrEnum): @@ -27,13 +27,15 @@ def __init__(self, url: AnyHttpUrl | str, **kwargs: Any) -> None: class Product(BaseModel): + model_config = ConfigDict(json_schema_serialization_defaults_required=True) + type_: Literal["Collection"] = Field(default="Collection", alias="type") stapi_type: Literal["Product"] = "Product" stapi_version: str = STAPI_VERSION conformsTo: list[str] = Field(default_factory=list) id: str title: str = "" - description: str = "" + description: str keywords: list[str] = Field(default_factory=list) license: str providers: list[Provider] = Field(default_factory=list) @@ -49,6 +51,10 @@ def with_links(self, links: list[Link] | None = None) -> Self: class ProductsCollection(BaseModel): - type_: Literal["ProductCollection"] = Field(default="ProductCollection", alias="type") + model_config = ConfigDict(serialize_by_alias=True, json_schema_serialization_defaults_required=True) + + stapi_type: Literal["ProductCollection"] = "ProductCollection" + stapi_version: str = STAPI_VERSION links: list[Link] = Field(default_factory=list) products: list[Product] + number_matched: NumberMatched = None diff --git a/stapi-pydantic/src/stapi_pydantic/root.py b/stapi-pydantic/src/stapi_pydantic/root.py index e42efae..e8f161e 100644 --- a/stapi-pydantic/src/stapi_pydantic/root.py +++ b/stapi-pydantic/src/stapi_pydantic/root.py @@ -1,9 +1,11 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from .shared import Link class RootResponse(BaseModel): + model_config = ConfigDict(json_schema_serialization_defaults_required=True) + id: str conformsTo: list[str] = Field(default_factory=list) title: str = "" diff --git a/stapi-pydantic/src/stapi_pydantic/search_parameters.py b/stapi-pydantic/src/stapi_pydantic/search_parameters.py new file mode 100644 index 0000000..b103b81 --- /dev/null +++ b/stapi-pydantic/src/stapi_pydantic/search_parameters.py @@ -0,0 +1,22 @@ +from geojson_pydantic.geometries import Geometry +from pydantic import BaseModel, ConfigDict + +from .datetime_interval import OpenDatetimeInterval +from .filter import CQL2Filter + + +class SearchParameters(BaseModel): + """STAPI Search Parameters Object. + + Shared request component constraining what could fulfill a request; used + by both the Opportunity Request and the Order Request. + See stapi-spec docs/spec/search-parameters/README.md. + """ + + datetime: OpenDatetimeInterval + geometry: Geometry + filter: CQL2Filter | None = None # type: ignore [type-arg] + + # Providers may supply vendor extension fields inside search parameters; + # they must round-trip through stored orders rather than being dropped. + model_config = ConfigDict(extra="allow") diff --git a/stapi-pydantic/src/stapi_pydantic/shared.py b/stapi-pydantic/src/stapi_pydantic/shared.py index 51558a8..e7cfda5 100644 --- a/stapi-pydantic/src/stapi_pydantic/shared.py +++ b/stapi-pydantic/src/stapi_pydantic/shared.py @@ -1,22 +1,26 @@ -from typing import Any +from typing import Annotated, Any from pydantic import ( AnyUrl, BaseModel, ConfigDict, - SerializerFunctionWrapHandler, - model_serializer, + Field, ) +# Reusable annotated type for the numberMatched collection field: serialized +# under its spec alias, omitted (and kept out of the serialization-required +# set) when None. +NumberMatched = Annotated[int | None, Field(serialization_alias="numberMatched", exclude_if=lambda x: x is None)] + class Link(BaseModel): href: AnyUrl rel: str - type: str | None = None - title: str | None = None - method: str | None = None - headers: dict[str, str | list[str]] | None = None - body: Any = None + type: str | None = Field(default=None, exclude_if=lambda v: v is None) + title: str | None = Field(default=None, exclude_if=lambda v: v is None) + method: str | None = Field(default=None, exclude_if=lambda v: v is None) + headers: dict[str, str | list[str]] | None = Field(default=None, exclude_if=lambda v: v is None) + body: Any = Field(default=None, exclude_if=lambda v: v is None) model_config = ConfigDict(extra="allow") @@ -24,9 +28,3 @@ class Link(BaseModel): # as str is ultimately coerced into an AnyUrl automatically anyway def __init__(self, href: Any, **kwargs: Any) -> None: super().__init__(href=href if isinstance(href, AnyUrl) else str(href), **kwargs) - - # overriding the default serialization to filter None field values from - # dumped json - @model_serializer(mode="wrap", when_used="json") - def serialize(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: - return {k: v for k, v in handler(self).items() if v is not None} diff --git a/stapi-pydantic/tests/test_filter.py b/stapi-pydantic/tests/test_filter.py new file mode 100644 index 0000000..ef82b50 --- /dev/null +++ b/stapi-pydantic/tests/test_filter.py @@ -0,0 +1,17 @@ +from stapi_pydantic.filter import cql2_property_names + + +def test_property_names_empty() -> None: + assert cql2_property_names(None) == set() + assert cql2_property_names({}) == set() + + +def test_property_names_nested() -> None: + filter_ = { + "op": "and", + "args": [ + {"op": ">=", "args": [{"property": "sar:resolution_range"}, 1.0]}, + {"op": "=", "args": [{"property": "platform"}, "umbra"]}, + ], + } + assert cql2_property_names(filter_) == {"sar:resolution_range", "platform"} diff --git a/stapi-pydantic/tests/test_opportunity.py b/stapi-pydantic/tests/test_opportunity.py index 922e9dd..912a28b 100644 --- a/stapi-pydantic/tests/test_opportunity.py +++ b/stapi-pydantic/tests/test_opportunity.py @@ -1,7 +1,185 @@ -from stapi_pydantic import OpportunityProperties +from typing import Any + +import pydantic +import pytest +from geojson_pydantic.geometries import Point +from stapi_pydantic import ( + Opportunity, + OpportunityCollection, + OpportunityProperties, + OpportunityRequest, + OpportunitySearchRecord, + OpportunitySearchRecordCollection, + OpportunitySearchStatus, + OpportunitySearchStatusCollection, + OrderParameters, + OrderRequest, +) + +SEARCH_PARAMS = { + "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", + "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, +} + + +def test_opportunity_search_status_accepts_extension_status_code() -> None: + status = OpportunitySearchStatus.model_validate({"timestamp": "2024-04-10T09:15:00Z", "status_code": "queued"}) + assert status.status_code == "queued" + assert status.model_dump(mode="json")["status_code"] == "queued" + + +def test_opportunity_search_status_code_constrainable_with_custom_enum() -> None: + from enum import StrEnum + + class NarrowCodes(StrEnum): + special = "special" + + with pytest.raises(pydantic.ValidationError): + OpportunitySearchStatus[NarrowCodes].model_validate( + {"timestamp": "2024-04-10T09:15:00Z", "status_code": "received"} + ) def test_create_properties() -> None: _ = OpportunityProperties.model_validate( {"datetime": "2025-04-01T00:00:00Z/2025-04-01T23:59:59Z", "product_id": "foo"} ) + + +def test_opportunity_request_shape() -> None: + req = OpportunityRequest.model_validate({"search_parameters": SEARCH_PARAMS}) + assert req.limit == 10 + assert req.next is None + + +def test_opportunity_request_search_body_is_order_request_shaped() -> None: + req = OpportunityRequest.model_validate({"search_parameters": SEARCH_PARAMS}) + body = req.search_body() + assert set(body) == {"search_parameters"} + assert body["search_parameters"]["geometry"]["type"] == "Point" + + +def test_search_body_is_valid_order_request() -> None: + req = OpportunityRequest.model_validate({"search_parameters": SEARCH_PARAMS}) + order_request = OrderRequest[OrderParameters].model_validate(req.search_body()) + assert order_request.search_parameters == req.search_parameters + + +def test_opportunity_request_body_includes_pagination() -> None: + req = OpportunityRequest.model_validate({"search_parameters": SEARCH_PARAMS, "next": "abc", "limit": 5}) + body = req.body() + assert body["next"] == "abc" + assert body["limit"] == 5 + assert "search_parameters" in body + + +SEARCH_RECORD_DICT = { + "id": "search-1", + "product_id": "umbra_spotlight", + "request": {"search_parameters": SEARCH_PARAMS}, + "status": { + "timestamp": "2024-04-18T11:00:00Z", + "status_code": "received", + "links": [], + }, +} + + +def test_opportunity_search_record_request_field() -> None: + record = OpportunitySearchRecord.model_validate(SEARCH_RECORD_DICT) + assert record.request.search_parameters.geometry.type == "Point" + dumped = record.model_dump(mode="json") + assert dumped["stapi_type"] == "OpportunitySearchRecord" + assert "opportunity_request" not in dumped + + +def test_opportunity_search_record_collection() -> None: + collection = OpportunitySearchRecordCollection(records=[OpportunitySearchRecord.model_validate(SEARCH_RECORD_DICT)]) + dumped = collection.model_dump(mode="json") + assert dumped["stapi_type"] == "OpportunitySearchRecordCollection" + assert len(dumped["records"]) == 1 + + +def test_opportunity_search_status_collection() -> None: + status = OpportunitySearchStatus.model_validate(SEARCH_RECORD_DICT["status"]) + collection = OpportunitySearchStatusCollection(statuses=[status]) + dumped = collection.model_dump(mode="json") + assert dumped["stapi_type"] == "OpportunitySearchStatusCollection" + + +def test_opportunity_collection_stapi_fields() -> None: + collection: OpportunityCollection[Any, Any] = OpportunityCollection(features=[]) + dumped = collection.model_dump(mode="json") + assert dumped["stapi_type"] == "OpportunityCollection" + assert dumped["stapi_version"] == "0.2.0" + + +OPPORTUNITY_DICT: dict[str, Any] = { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, + "properties": { + "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", + "product_id": "umbra_spotlight", + }, +} + + +def test_opportunity_bbox_3d_geometry() -> None: + opportunity_dict: dict[str, Any] = { + **OPPORTUNITY_DICT, + "geometry": { + "type": "LineString", + "coordinates": [[13.0, 52.0, 10.0], [14.0, 53.0, 200.0]], + }, + } + opportunity: Opportunity[Any, Any] = Opportunity.model_validate(opportunity_dict) + assert opportunity.model_dump(mode="json")["bbox"] == [13.0, 52.0, 10.0, 14.0, 53.0, 200.0] + + +def test_opportunity_serialization_schema_marks_spec_required_fields() -> None: + schema = Opportunity[Point, OpportunityProperties].model_json_schema(mode="serialization") + assert {"type", "stapi_type", "stapi_version", "links", "bbox"} <= set(schema["required"]) + assert {"type": "null"} not in schema["properties"]["bbox"].get("anyOf", []) + + +def test_opportunity_id_is_string_only() -> None: + schema = Opportunity[Point, OpportunityProperties].model_json_schema(mode="validation") + id_types = {member.get("type") for member in schema["properties"]["id"].get("anyOf", [])} + assert "integer" not in id_types + + +def test_opportunity_collection_omits_null_id() -> None: + collection: OpportunityCollection[Any, Any] = OpportunityCollection(features=[]) + assert "id" not in collection.model_dump(mode="json") + assert '"id":null' not in collection.model_dump_json() + + +def test_opportunity_collection_number_matched() -> None: + collection: OpportunityCollection[Any, Any] = OpportunityCollection(features=[], number_matched=3) + assert collection.model_dump(mode="json")["numberMatched"] == 3 + assert "numberMatched" not in OpportunityCollection(features=[]).model_dump(mode="json") + + +def test_search_record_collection_number_matched() -> None: + collection = OpportunitySearchRecordCollection(records=[], number_matched=0) + assert collection.model_dump(mode="json")["numberMatched"] == 0 + + +def test_opportunity_geometry_required_non_null() -> None: + with pytest.raises(pydantic.ValidationError): + Opportunity[Point, OpportunityProperties].model_validate( + { + **OPPORTUNITY_DICT, + "geometry": None, + } + ) + + +def test_opportunity_properties_required() -> None: + with pytest.raises(pydantic.ValidationError): + Opportunity[Point, OpportunityProperties].model_validate( + { + **OPPORTUNITY_DICT, + "properties": None, + } + ) diff --git a/stapi-pydantic/tests/test_order.py b/stapi-pydantic/tests/test_order.py index 83c4778..58138ed 100644 --- a/stapi-pydantic/tests/test_order.py +++ b/stapi-pydantic/tests/test_order.py @@ -1,6 +1,24 @@ import datetime +from enum import StrEnum +from typing import Any -from stapi_pydantic import OrderStatus, OrderStatusCode +import pydantic +import pytest +from stapi_pydantic import ( + BaseOrderParameters, + Order, + OrderCollection, + OrderParameters, + OrderRequest, + OrderStatus, + OrderStatusCode, + StoredOrderRequest, +) + +SEARCH_PARAMS = { + "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", + "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, +} def test_order_status_new() -> None: @@ -10,3 +28,175 @@ def test_order_status_new() -> None: assert status.reason_code is None assert status.reason_text is None assert status.links == [] + + +def test_order_status_accepts_extension_status_code() -> None: + status = OrderStatus.model_validate({"timestamp": "2024-04-10T09:15:00Z", "status_code": "tasking_window_open"}) + assert status.status_code == "tasking_window_open" + assert status.model_dump(mode="json")["status_code"] == "tasking_window_open" + + +def test_order_status_known_code_validates_to_enum() -> None: + status = OrderStatus.model_validate({"timestamp": "2024-04-10T09:15:00Z", "status_code": "received"}) + assert status.status_code is OrderStatusCode.received + + +def test_order_status_code_constrainable_with_custom_enum() -> None: + class NarrowCodes(StrEnum): + special = "special" + + narrowed = OrderStatus[NarrowCodes] + assert narrowed.model_validate({"timestamp": "2024-04-10T09:15:00Z", "status_code": "special"}).status_code is ( + NarrowCodes.special + ) + with pytest.raises(pydantic.ValidationError): + narrowed.model_validate({"timestamp": "2024-04-10T09:15:00Z", "status_code": "received"}) + + +def test_order_status_code_schema_allows_extension_strings() -> None: + status_code_schema = OrderStatus.model_json_schema()["properties"]["status_code"] + assert {"type": "string"} in status_code_schema["anyOf"] + assert any("$ref" in member for member in status_code_schema["anyOf"]) + + +class RequiredParams(OrderParameters): + delivery_format: str + + +def test_order_request_shape() -> None: + req = OrderRequest[OrderParameters].model_validate({"search_parameters": SEARCH_PARAMS, "order_parameters": {}}) + assert req.search_parameters.filter is None + + +def test_order_request_omitted_order_parameters_is_empty_object() -> None: + req = OrderRequest[OrderParameters].model_validate({"search_parameters": SEARCH_PARAMS}) + assert req.order_parameters == OrderParameters() + + +def test_order_request_omitted_order_parameters_fails_when_required() -> None: + with pytest.raises(pydantic.ValidationError): + OrderRequest[RequiredParams].model_validate({"search_parameters": SEARCH_PARAMS}) + + +ORDER_DICT: dict[str, Any] = { + "id": "order-1", + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, + "properties": { + "product_id": "umbra_spotlight", + "created": "2024-04-10T09:15:00Z", + "status": { + "timestamp": "2024-04-10T09:15:00Z", + "status_code": "received", + "links": [], + }, + "order_request": {"search_parameters": SEARCH_PARAMS}, + "owner": {"organization": "ACME"}, + }, +} + + +def test_order_properties_order_request() -> None: + order = Order[OrderStatus].model_validate(ORDER_DICT) + assert order.properties.order_request.order_parameters == BaseOrderParameters() + assert order.properties.status.status_code == OrderStatusCode.received + + +def test_stored_order_parameters_preserve_provider_fields() -> None: + order_dict = { + **ORDER_DICT, + "properties": { + **ORDER_DICT["properties"], + "order_request": { + "search_parameters": SEARCH_PARAMS, + "order_parameters": {"deliveryFormat": "GEOTIFF"}, + }, + }, + } + order = Order[OrderStatus].model_validate(order_dict) + params = order.properties.order_request.order_parameters + assert isinstance(params, BaseOrderParameters) + assert params.model_dump()["deliveryFormat"] == "GEOTIFF" + + +def test_concrete_order_parameters_are_base_order_parameters() -> None: + assert isinstance(RequiredParams(delivery_format="GEOTIFF"), BaseOrderParameters) + + # RequiredParams (via OrderParameters) forbids extra fields... + with pytest.raises(pydantic.ValidationError): + RequiredParams.model_validate({"delivery_format": "GEOTIFF", "unexpected_field": "value"}) + + # ...while BaseOrderParameters allows and preserves them. + base = BaseOrderParameters.model_validate({"unexpected_field": "value"}) + assert base.model_dump()["unexpected_field"] == "value" + + +def test_order_extra_properties_allowed() -> None: + order = Order[OrderStatus].model_validate(ORDER_DICT) + assert order.properties.model_dump()["owner"] == {"organization": "ACME"} + + +def test_order_bbox_computed_and_serialized() -> None: + order = Order[OrderStatus].model_validate(ORDER_DICT) + dumped = order.model_dump(mode="json") + assert dumped["bbox"] == [13.4, 52.5, 13.4, 52.5] + + +def test_order_bbox_3d_geometry() -> None: + order_dict: dict[str, Any] = { + **ORDER_DICT, + "geometry": { + "type": "LineString", + "coordinates": [[13.0, 52.0, 10.0], [14.0, 53.0, 200.0]], + }, + } + order = Order[OrderStatus].model_validate(order_dict) + assert order.model_dump(mode="json")["bbox"] == [13.0, 52.0, 10.0, 14.0, 53.0, 200.0] + + +def test_order_serialization_schema_marks_spec_required_fields() -> None: + schema = Order[OrderStatus].model_json_schema(mode="serialization") + assert {"type", "stapi_type", "stapi_version", "links", "bbox"} <= set(schema["required"]) + + +def test_order_bbox_serialization_schema_is_not_nullable() -> None: + schema = Order[OrderStatus].model_json_schema(mode="serialization") + bbox = schema["properties"]["bbox"] + assert {"type": "null"} not in bbox.get("anyOf", []) + + +def test_order_collection_number_matched_not_serialization_required() -> None: + schema = OrderCollection[OrderStatus].model_json_schema(mode="serialization") + assert {"type", "stapi_type", "stapi_version", "links", "features"} <= set(schema["required"]) + assert "numberMatched" not in schema["required"] + + +def test_stored_order_request_preserves_unknown_fields() -> None: + stored = StoredOrderRequest.model_validate({"search_parameters": SEARCH_PARAMS, "provider_extra": 1}) + assert stored.model_dump()["provider_extra"] == 1 + + +def test_search_parameters_preserve_unknown_fields() -> None: + order = Order[OrderStatus].model_validate( + { + **ORDER_DICT, + "properties": { + **ORDER_DICT["properties"], + "order_request": {"search_parameters": {**SEARCH_PARAMS, "vendor:priority": "high"}}, + }, + } + ) + dumped = order.model_dump(mode="json") + assert dumped["properties"]["order_request"]["search_parameters"]["vendor:priority"] == "high" + + +def test_order_empty_geometry_bbox_error_is_clear() -> None: + with pytest.raises(pydantic.ValidationError, match="bbox"): + Order[OrderStatus].model_validate({**ORDER_DICT, "geometry": {"type": "MultiPoint", "coordinates": []}}) + + +def test_order_collection_stapi_fields() -> None: + collection = OrderCollection[OrderStatus](features=[Order[OrderStatus].model_validate(ORDER_DICT)]) + dumped = collection.model_dump(mode="json") + assert dumped["stapi_type"] == "OrderCollection" + assert dumped["stapi_version"] == "0.2.0" diff --git a/stapi-pydantic/tests/test_product.py b/stapi-pydantic/tests/test_product.py new file mode 100644 index 0000000..46a26df --- /dev/null +++ b/stapi-pydantic/tests/test_product.py @@ -0,0 +1,27 @@ +import pydantic +import pytest +from stapi_pydantic import Product, ProductsCollection + + +def test_products_collection_stapi_fields() -> None: + collection = ProductsCollection(products=[Product(id="p1", license="proprietary", description="d")]) + dumped = collection.model_dump(mode="json") + assert dumped["stapi_type"] == "ProductCollection" + assert dumped["stapi_version"] == "0.2.0" + assert "type" not in dumped + + +def test_product_description_is_required() -> None: + with pytest.raises(pydantic.ValidationError, match="description"): + Product.model_validate({"id": "p1", "license": "proprietary"}) + + +def test_product_serialization_schema_marks_spec_required_fields() -> None: + schema = Product.model_json_schema(mode="serialization") + assert {"type", "stapi_type", "stapi_version", "id", "description", "license", "links"} <= set(schema["required"]) + + +def test_products_collection_number_matched() -> None: + collection = ProductsCollection(products=[], number_matched=12) + assert collection.model_dump(mode="json")["numberMatched"] == 12 + assert "numberMatched" not in ProductsCollection(products=[]).model_dump(mode="json") diff --git a/stapi-pydantic/tests/test_search_parameters.py b/stapi-pydantic/tests/test_search_parameters.py new file mode 100644 index 0000000..414068c --- /dev/null +++ b/stapi-pydantic/tests/test_search_parameters.py @@ -0,0 +1,57 @@ +import pytest +from pydantic import ValidationError +from stapi_pydantic import STAPI_VERSION, SearchParameters + +GEOMETRY = {"type": "Point", "coordinates": [13.4, 52.5]} + + +def test_stapi_version_is_0_2_0() -> None: + assert STAPI_VERSION == "0.2.0" + + +def test_search_parameters_minimal() -> None: + sp = SearchParameters.model_validate( + { + "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", + "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, + } + ) + assert sp.filter is None + + +@pytest.mark.parametrize("interval", ["2024-04-18T10:56:00Z/..", "2024-04-18T10:56:00Z/"]) +def test_search_parameters_open_end(interval: str) -> None: + sp = SearchParameters.model_validate({"datetime": interval, "geometry": GEOMETRY}) + assert sp.datetime[0] is not None + assert sp.datetime[1] is None + assert sp.model_dump(mode="json")["datetime"] == "2024-04-18T10:56:00+00:00/.." + + +@pytest.mark.parametrize("interval", ["../2024-04-25T10:56:00+01:00", "/2024-04-25T10:56:00+01:00"]) +def test_search_parameters_open_start(interval: str) -> None: + sp = SearchParameters.model_validate({"datetime": interval, "geometry": GEOMETRY}) + assert sp.datetime[0] is None + assert sp.datetime[1] is not None + assert sp.model_dump(mode="json")["datetime"] == "../2024-04-25T10:56:00+01:00" + + +@pytest.mark.parametrize("interval", ["../..", "/", "../", "/.."]) +def test_search_parameters_doubly_open_interval_rejected(interval: str) -> None: + with pytest.raises(ValidationError): + SearchParameters.model_validate({"datetime": interval, "geometry": GEOMETRY}) + + +def test_search_parameters_end_before_start_rejected() -> None: + with pytest.raises(ValidationError, match="end before start"): + SearchParameters.model_validate({"datetime": "2024-04-25T10:56:00Z/2024-04-18T10:56:00Z", "geometry": GEOMETRY}) + + +def test_search_parameters_with_filter() -> None: + sp = SearchParameters.model_validate( + { + "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", + "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, + "filter": {"op": ">=", "args": [{"property": "gsd"}, 1.0]}, + } + ) + assert sp.filter is not None diff --git a/stapi-pydantic/tests/test_shared.py b/stapi-pydantic/tests/test_shared.py new file mode 100644 index 0000000..653f2f0 --- /dev/null +++ b/stapi-pydantic/tests/test_shared.py @@ -0,0 +1,30 @@ +from stapi_pydantic import Conformance, Link, RootResponse + + +def test_link_serialization_schema_is_structured() -> None: + schema = Link.model_json_schema(mode="serialization") + assert {"href", "rel"} <= set(schema["required"]) + assert "href" in schema["properties"] + + +def test_link_json_dump_omits_none_fields() -> None: + link = Link(href="https://example.com/orders/1", rel="self") + dumped = link.model_dump(mode="json") + assert dumped["rel"] == "self" + assert "title" not in dumped + assert "body" not in dumped + + +def test_link_preserves_extra_fields() -> None: + link = Link.model_validate({"href": "https://example.com", "rel": "self", "vendor:hint": "x"}) + assert link.model_dump(mode="json")["vendor:hint"] == "x" + + +def test_root_response_serialization_schema_marks_spec_required_fields() -> None: + schema = RootResponse.model_json_schema(mode="serialization") + assert {"id", "conformsTo", "description", "links"} <= set(schema["required"]) + + +def test_conformance_serialization_schema_requires_conforms_to() -> None: + schema = Conformance.model_json_schema(mode="serialization", by_alias=True) + assert "conformsTo" in schema.get("required", []) diff --git a/uv.lock b/uv.lock index 56e280c..9334062 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", @@ -1761,6 +1762,7 @@ version = "0.0.0" source = { virtual = "." } dependencies = [ { name = "pystapi-client" }, + { name = "pystapi-schema-generator" }, { name = "pystapi-validator" }, { name = "stapi-fastapi" }, { name = "stapi-pydantic" }, @@ -1773,7 +1775,9 @@ dev = [ { name = "pre-commit-hooks" }, { name = "pygithub" }, { name = "pymarkdownlnt" }, + { name = "pyyaml" }, { name = "ruff" }, + { name = "types-pyyaml" }, ] docs = [ { name = "mkdocs-material" }, @@ -1783,6 +1787,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" }, @@ -1795,7 +1800,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" }, @@ -1804,7 +1811,7 @@ docs = [ [[package]] name = "pystapi-client" -version = "0.0.1" +version = "0.0.2" source = { editable = "pystapi-client" } dependencies = [ { name = "click" }, @@ -1835,6 +1842,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" @@ -2573,7 +2603,7 @@ wheels = [ [[package]] name = "stapi-fastapi" -version = "0.8.0" +version = "0.9.0" source = { editable = "stapi-fastapi" } dependencies = [ { name = "fastapi" }, @@ -2616,12 +2646,13 @@ dev = [ [[package]] name = "stapi-pydantic" -version = "0.1.0" +version = "0.2.0" source = { editable = "stapi-pydantic" } dependencies = [ { name = "cql2" }, { name = "geojson-pydantic" }, { name = "pydantic" }, + { name = "typing-extensions" }, ] [package.dev-dependencies] @@ -2634,6 +2665,7 @@ requires-dist = [ { name = "cql2", specifier = ">=0.3.6" }, { name = "geojson-pydantic", specifier = ">=1.2.0" }, { name = "pydantic", specifier = ">=2.12" }, + { name = "typing-extensions", specifier = ">=4.12" }, ] [package.metadata.requires-dev] @@ -2756,6 +2788,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"