From 575e500f80561dbcd30b99bfce9cd8663593bc08 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 23 Jul 2026 19:38:18 -0700 Subject: [PATCH 01/25] feat!: bump STAPI_VERSION to 0.2.0 and add shared SearchParameters model --- stapi-pydantic/src/stapi_pydantic/__init__.py | 4 +++ .../src/stapi_pydantic/constants.py | 2 +- stapi-pydantic/src/stapi_pydantic/order.py | 7 ++--- .../src/stapi_pydantic/search_parameters.py | 18 +++++++++++ .../tests/test_search_parameters.py | 30 +++++++++++++++++++ 5 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 stapi-pydantic/src/stapi_pydantic/search_parameters.py create mode 100644 stapi-pydantic/tests/test_search_parameters.py diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index 44ecbd0..dbc2aa5 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -1,4 +1,5 @@ from .conformance import Conformance +from .constants import STAPI_VERSION from .datetime_interval import DatetimeInterval from .filter import CQL2Filter from .json_schema_model import JsonSchemaModel @@ -27,6 +28,7 @@ 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__ = [ @@ -59,4 +61,6 @@ "ProviderRole", "Queryables", "RootResponse", + "SearchParameters", + "STAPI_VERSION", ] 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/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index 159b341..8469b90 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -20,6 +20,7 @@ from .datetime_interval import DatetimeInterval from .filter import CQL2Filter from .opportunity import OpportunityProperties +from .search_parameters import SearchParameters from .shared import Link Props = TypeVar("Props", bound=dict[str, Any] | BaseModel) @@ -80,11 +81,7 @@ class OrderStatuses(BaseModel, Generic[T]): links: list[Link] = Field(default_factory=list) -class OrderSearchParameters(BaseModel): - datetime: DatetimeInterval - geometry: Geometry - # TODO: validate the CQL2 filter? - filter: CQL2Filter | None = None # type: ignore [type-arg] +OrderSearchParameters = SearchParameters class OrderProperties(BaseModel, Generic[T]): 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..f7ead4c --- /dev/null +++ b/stapi-pydantic/src/stapi_pydantic/search_parameters.py @@ -0,0 +1,18 @@ +from geojson_pydantic.geometries import Geometry +from pydantic import BaseModel + +from .datetime_interval import DatetimeInterval +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: DatetimeInterval + geometry: Geometry + filter: CQL2Filter | None = None # type: ignore [type-arg] diff --git a/stapi-pydantic/tests/test_search_parameters.py b/stapi-pydantic/tests/test_search_parameters.py new file mode 100644 index 0000000..e220135 --- /dev/null +++ b/stapi-pydantic/tests/test_search_parameters.py @@ -0,0 +1,30 @@ +from stapi_pydantic import STAPI_VERSION, OrderSearchParameters, SearchParameters + + +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 + + +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 + + +def test_order_search_parameters_alias() -> None: + assert OrderSearchParameters is SearchParameters From 7e9e331aba577df0769f3d3757cbd32340df463c Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 23 Jul 2026 19:41:57 -0700 Subject: [PATCH 02/25] feat!: OrderRequest composes SearchParameters with optional order_parameters --- stapi-pydantic/src/stapi_pydantic/__init__.py | 2 ++ stapi-pydantic/src/stapi_pydantic/order.py | 21 +++++++----- stapi-pydantic/tests/test_order.py | 34 ++++++++++++++++++- 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index dbc2aa5..646a9fb 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -20,6 +20,7 @@ OrderParameters, OrderPayload, OrderProperties, + OrderRequest, OrderSearchParameters, OrderStatus, OrderStatusCode, @@ -50,6 +51,7 @@ "OrderParameters", "OrderPayload", "OrderProperties", + "OrderRequest", "OrderSearchParameters", "OrderStatus", "OrderStatusCode", diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index 8469b90..4155b09 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -17,8 +17,6 @@ ) from .constants import STAPI_VERSION -from .datetime_interval import DatetimeInterval -from .filter import CQL2Filter from .opportunity import OpportunityProperties from .search_parameters import SearchParameters from .shared import Link @@ -143,12 +141,19 @@ 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. - order_parameters: ORP + 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. + """ + + search_parameters: SearchParameters + order_parameters: ORP = Field(default_factory=dict, validate_default=True) model_config = ConfigDict(strict=True) + + +OrderPayload = OrderRequest diff --git a/stapi-pydantic/tests/test_order.py b/stapi-pydantic/tests/test_order.py index 83c4778..f2d2e6d 100644 --- a/stapi-pydantic/tests/test_order.py +++ b/stapi-pydantic/tests/test_order.py @@ -1,6 +1,13 @@ import datetime -from stapi_pydantic import OrderStatus, OrderStatusCode +import pydantic +import pytest +from stapi_pydantic import OrderParameters, OrderPayload, OrderRequest, OrderStatus, OrderStatusCode + +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 +17,28 @@ def test_order_status_new() -> None: assert status.reason_code is None assert status.reason_text is None assert status.links == [] + + +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}) + + +def test_order_payload_alias() -> None: + assert OrderPayload is OrderRequest From a5026e011901cc74a72ae37c072fab3ec117092d Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 23 Jul 2026 19:45:44 -0700 Subject: [PATCH 03/25] feat!: OpportunityRequest composes SearchParameters Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RhnGNojViYvYDevhtz999a --- stapi-pydantic/src/stapi_pydantic/__init__.py | 2 ++ .../src/stapi_pydantic/opportunity.py | 20 +++++++---- stapi-pydantic/tests/test_opportunity.py | 34 ++++++++++++++++++- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index 646a9fb..93bab01 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -8,6 +8,7 @@ OpportunityCollection, OpportunityPayload, OpportunityProperties, + OpportunityRequest, OpportunitySearchRecord, OpportunitySearchRecords, OpportunitySearchStatus, @@ -42,6 +43,7 @@ "OpportunityCollection", "OpportunityPayload", "OpportunityProperties", + "OpportunityRequest", "OpportunitySearchRecord", "OpportunitySearchRecords", "OpportunitySearchStatus", diff --git a/stapi-pydantic/src/stapi_pydantic/opportunity.py b/stapi-pydantic/src/stapi_pydantic/opportunity.py index a20a9fc..6afb155 100644 --- a/stapi-pydantic/src/stapi_pydantic/opportunity.py +++ b/stapi-pydantic/src/stapi_pydantic/opportunity.py @@ -6,7 +6,7 @@ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field from .datetime_interval import DatetimeInterval -from .filter import CQL2Filter +from .search_parameters import SearchParameters from .shared import Link @@ -17,10 +17,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,12 +33,15 @@ 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") +OpportunityPayload = OpportunityRequest + + G = TypeVar("G", bound=Geometry) P = TypeVar("P", bound=OpportunityProperties) diff --git a/stapi-pydantic/tests/test_opportunity.py b/stapi-pydantic/tests/test_opportunity.py index 922e9dd..ce4c654 100644 --- a/stapi-pydantic/tests/test_opportunity.py +++ b/stapi-pydantic/tests/test_opportunity.py @@ -1,7 +1,39 @@ -from stapi_pydantic import OpportunityProperties +from stapi_pydantic import OpportunityPayload, OpportunityProperties, OpportunityRequest + +SEARCH_PARAMS = { + "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", + "geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, +} 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_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 + + +def test_opportunity_payload_alias() -> None: + assert OpportunityPayload is OpportunityRequest From 395920b9b6ce1bdd768ceb83066da8268bb8514d Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 23 Jul 2026 19:50:54 -0700 Subject: [PATCH 04/25] feat!: Order properties nest order_request; require bbox; OrderCollection stapi fields --- stapi-pydantic/src/stapi_pydantic/__init__.py | 4 + stapi-pydantic/src/stapi_pydantic/order.py | 59 +++++++++++++-- stapi-pydantic/tests/test_order.py | 75 ++++++++++++++++++- 3 files changed, 131 insertions(+), 7 deletions(-) diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index 93bab01..8cc6061 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -16,6 +16,7 @@ Prefer, ) from .order import ( + BaseOrderParameters, Order, OrderCollection, OrderParameters, @@ -26,6 +27,7 @@ OrderStatus, OrderStatusCode, OrderStatuses, + StoredOrderRequest, ) from .product import Product, ProductsCollection, Provider, ProviderRole from .queryables import Queryables @@ -34,6 +36,7 @@ from .shared import Link __all__ = [ + "BaseOrderParameters", "Conformance", "CQL2Filter", "DatetimeInterval", @@ -66,5 +69,6 @@ "Queryables", "RootResponse", "SearchParameters", + "StoredOrderRequest", "STAPI_VERSION", ] diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index 4155b09..6cec864 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -14,6 +14,7 @@ Field, StrictStr, field_validator, + model_validator, ) from .constants import STAPI_VERSION @@ -25,7 +26,20 @@ 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") @@ -82,18 +96,40 @@ class OrderStatuses(BaseModel, Generic[T]): OrderSearchParameters = SearchParameters +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. + """ + + search_parameters: SearchParameters + order_parameters: BaseOrderParameters = Field(default_factory=BaseOrderParameters) + + class OrderProperties(BaseModel, Generic[T]): product_id: str created: AwareDatetime status: T - - search_parameters: OrderSearchParameters - opportunity_properties: dict[str, Any] - order_parameters: dict[str, Any] + order_request: StoredOrderRequest model_config = ConfigDict(extra="allow") +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) + + # derived from geojson_pydantic.Feature class Order(_GeoJsonBase, Generic[T]): # We need to enforce that orders have an id defined, as that is required to @@ -108,7 +144,7 @@ class Order(_GeoJsonBase, Generic[T]): 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: @@ -118,10 +154,21 @@ def set_geometry(cls, geometry: Any) -> Any: return geometry + @model_validator(mode="after") + def compute_bbox(self) -> Order[T]: + if self.bbox is None: + coords = _all_coordinates(self.geometry) + lons = [c[0] for c in coords] + lats = [c[1] for c in coords] + self.bbox = (min(lons), min(lats), max(lons), max(lats)) + return self + # derived from geojson_pydantic.FeatureCollection class OrderCollection(_GeoJsonBase, Generic[T]): type: Literal["FeatureCollection"] = "FeatureCollection" + stapi_type: Literal["OrderCollection"] = "OrderCollection" + stapi_version: str = STAPI_VERSION features: list[Order[T]] links: list[Link] = Field(default_factory=list) number_matched: int | None = Field( diff --git a/stapi-pydantic/tests/test_order.py b/stapi-pydantic/tests/test_order.py index f2d2e6d..204c05a 100644 --- a/stapi-pydantic/tests/test_order.py +++ b/stapi-pydantic/tests/test_order.py @@ -1,8 +1,18 @@ import datetime +from typing import Any import pydantic import pytest -from stapi_pydantic import OrderParameters, OrderPayload, OrderRequest, OrderStatus, OrderStatusCode +from stapi_pydantic import ( + BaseOrderParameters, + Order, + OrderCollection, + OrderParameters, + OrderPayload, + OrderRequest, + OrderStatus, + OrderStatusCode, +) SEARCH_PARAMS = { "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", @@ -42,3 +52,66 @@ def test_order_request_omitted_order_parameters_fails_when_required() -> None: def test_order_payload_alias() -> None: assert OrderPayload is OrderRequest + + +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) + + +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_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" From d64d01f738c0c953d30f23026b4526913231528f Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 23 Jul 2026 19:56:03 -0700 Subject: [PATCH 05/25] fix: compute 3D bboxes per RFC 7946; strengthen order parameters typing test --- stapi-pydantic/src/stapi_pydantic/order.py | 17 +++++++++++++---- stapi-pydantic/tests/test_order.py | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index 6cec864..4d6c3ab 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -7,6 +7,7 @@ from geojson_pydantic.base import _GeoJsonBase from geojson_pydantic.geometries import Geometry +from geojson_pydantic.types import BBox from pydantic import ( AwareDatetime, BaseModel, @@ -130,6 +131,17 @@ def flatten(coords: Any) -> list[list[float]]: 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) + 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)) + + # derived from geojson_pydantic.Feature class Order(_GeoJsonBase, Generic[T]): # We need to enforce that orders have an id defined, as that is required to @@ -157,10 +169,7 @@ def set_geometry(cls, geometry: Any) -> Any: @model_validator(mode="after") def compute_bbox(self) -> Order[T]: if self.bbox is None: - coords = _all_coordinates(self.geometry) - lons = [c[0] for c in coords] - lats = [c[1] for c in coords] - self.bbox = (min(lons), min(lats), max(lons), max(lats)) + self.bbox = compute_geometry_bbox(self.geometry) return self diff --git a/stapi-pydantic/tests/test_order.py b/stapi-pydantic/tests/test_order.py index 204c05a..f4ab073 100644 --- a/stapi-pydantic/tests/test_order.py +++ b/stapi-pydantic/tests/test_order.py @@ -98,6 +98,14 @@ def test_stored_order_parameters_preserve_provider_fields() -> None: 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) @@ -110,6 +118,18 @@ def test_order_bbox_computed_and_serialized() -> None: 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_collection_stapi_fields() -> None: collection = OrderCollection[OrderStatus](features=[Order[OrderStatus].model_validate(ORDER_DICT)]) dumped = collection.model_dump(mode="json") From 619291e067c411badd8edf1cbab0c1935f6398af Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 23 Jul 2026 20:03:05 -0700 Subject: [PATCH 06/25] feat!: opportunity entities gain stapi fields; search record uses request/records --- stapi-pydantic/src/stapi_pydantic/__init__.py | 4 + stapi-pydantic/src/stapi_pydantic/geometry.py | 28 +++++++ .../src/stapi_pydantic/opportunity.py | 36 ++++++++- stapi-pydantic/src/stapi_pydantic/order.py | 26 +----- stapi-pydantic/tests/test_opportunity.py | 79 ++++++++++++++++++- 5 files changed, 143 insertions(+), 30 deletions(-) create mode 100644 stapi-pydantic/src/stapi_pydantic/geometry.py diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index 8cc6061..cd1ece0 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -10,9 +10,11 @@ OpportunityProperties, OpportunityRequest, OpportunitySearchRecord, + OpportunitySearchRecordCollection, OpportunitySearchRecords, OpportunitySearchStatus, OpportunitySearchStatusCode, + OpportunitySearchStatusCollection, Prefer, ) from .order import ( @@ -48,9 +50,11 @@ "OpportunityProperties", "OpportunityRequest", "OpportunitySearchRecord", + "OpportunitySearchRecordCollection", "OpportunitySearchRecords", "OpportunitySearchStatus", "OpportunitySearchStatusCode", + "OpportunitySearchStatusCollection", "Order", "OrderCollection", "OrderParameters", diff --git a/stapi-pydantic/src/stapi_pydantic/geometry.py b/stapi-pydantic/src/stapi_pydantic/geometry.py new file mode 100644 index 0000000..3006114 --- /dev/null +++ b/stapi-pydantic/src/stapi_pydantic/geometry.py @@ -0,0 +1,28 @@ +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) + 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 6afb155..864c6c4 100644 --- a/stapi-pydantic/src/stapi_pydantic/opportunity.py +++ b/stapi-pydantic/src/stapi_pydantic/opportunity.py @@ -1,11 +1,15 @@ +from __future__ import annotations + from enum import StrEnum from typing import Any, Literal, TypeVar from geojson_pydantic import Feature, FeatureCollection from geojson_pydantic.geometries import Geometry -from pydantic import AwareDatetime, BaseModel, ConfigDict, Field +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator +from .constants import STAPI_VERSION from .datetime_interval import DatetimeInterval +from .geometry import compute_geometry_bbox from .search_parameters import SearchParameters from .shared import Link @@ -48,11 +52,21 @@ def body(self) -> dict[str, Any]: class Opportunity(Feature[G, P]): type: Literal["Feature"] = "Feature" + stapi_type: Literal["Opportunity"] = "Opportunity" + stapi_version: str = STAPI_VERSION links: list[Link] = Field(default_factory=list) + @model_validator(mode="after") + def compute_bbox(self) -> Opportunity[G, P]: + if self.bbox is None and self.geometry is not None: + self.bbox = compute_geometry_bbox(self.geometry) + return self + class OpportunityCollection(FeatureCollection[Opportunity[G, P]]): type: Literal["FeatureCollection"] = "FeatureCollection" + stapi_type: Literal["OpportunityCollection"] = "OpportunityCollection" + stapi_version: str = STAPI_VERSION links: list[Link] = Field(default_factory=list) id: str | None = None @@ -76,13 +90,27 @@ class OpportunitySearchStatus(BaseModel): class OpportunitySearchRecord(BaseModel): 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): + stapi_type: Literal["OpportunitySearchRecordCollection"] = "OpportunitySearchRecordCollection" + stapi_version: str = STAPI_VERSION + records: list[OpportunitySearchRecord] + links: list[Link] = Field(default_factory=list) + + +OpportunitySearchRecords = OpportunitySearchRecordCollection + + +class OpportunitySearchStatusCollection(BaseModel): + stapi_type: Literal["OpportunitySearchStatusCollection"] = "OpportunitySearchStatusCollection" + stapi_version: str = STAPI_VERSION + statuses: list[OpportunitySearchStatus] links: list[Link] = Field(default_factory=list) diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index 4d6c3ab..34fd88c 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -7,7 +7,6 @@ from geojson_pydantic.base import _GeoJsonBase from geojson_pydantic.geometries import Geometry -from geojson_pydantic.types import BBox from pydantic import ( AwareDatetime, BaseModel, @@ -19,6 +18,7 @@ ) from .constants import STAPI_VERSION +from .geometry import compute_geometry_bbox from .opportunity import OpportunityProperties from .search_parameters import SearchParameters from .shared import Link @@ -118,30 +118,6 @@ class OrderProperties(BaseModel, Generic[T]): model_config = ConfigDict(extra="allow") -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) - 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)) - - # derived from geojson_pydantic.Feature class Order(_GeoJsonBase, Generic[T]): # We need to enforce that orders have an id defined, as that is required to diff --git a/stapi-pydantic/tests/test_opportunity.py b/stapi-pydantic/tests/test_opportunity.py index ce4c654..5c67283 100644 --- a/stapi-pydantic/tests/test_opportunity.py +++ b/stapi-pydantic/tests/test_opportunity.py @@ -1,4 +1,16 @@ -from stapi_pydantic import OpportunityPayload, OpportunityProperties, OpportunityRequest +from typing import Any + +from stapi_pydantic import ( + Opportunity, + OpportunityCollection, + OpportunityPayload, + OpportunityProperties, + OpportunityRequest, + OpportunitySearchRecord, + OpportunitySearchRecordCollection, + OpportunitySearchStatus, + OpportunitySearchStatusCollection, +) SEARCH_PARAMS = { "datetime": "2024-04-18T10:56:00Z/2024-04-25T10:56:00Z", @@ -37,3 +49,68 @@ def test_opportunity_request_body_includes_pagination() -> None: def test_opportunity_payload_alias() -> None: assert OpportunityPayload is OpportunityRequest + + +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] From 0a7d5109b7c04f6d39fc16be964e805850e1fbc5 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 23 Jul 2026 20:06:48 -0700 Subject: [PATCH 07/25] fix: Opportunity geometry and properties are required and non-nullable Feature[G, P] from geojson_pydantic types geometry and properties as optional/nullable. Opportunity never narrowed these, so model_validate accepted {"geometry": None, ...}, which then skipped bbox computation in compute_bbox and produced a dump violating the v0.2.0 spec (geometry and bbox are both REQUIRED, non-null). Override geometry and properties on Opportunity to use the class's own bound TypeVars (G, P) as required fields, and drop the "geometry is not None" guard in compute_bbox now that geometry can no longer be None. --- .../src/stapi_pydantic/opportunity.py | 4 +++- stapi-pydantic/tests/test_opportunity.py | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/stapi-pydantic/src/stapi_pydantic/opportunity.py b/stapi-pydantic/src/stapi_pydantic/opportunity.py index 864c6c4..2cc7a2c 100644 --- a/stapi-pydantic/src/stapi_pydantic/opportunity.py +++ b/stapi-pydantic/src/stapi_pydantic/opportunity.py @@ -54,11 +54,13 @@ class Opportunity(Feature[G, P]): type: Literal["Feature"] = "Feature" stapi_type: Literal["Opportunity"] = "Opportunity" stapi_version: str = STAPI_VERSION + geometry: G = Field(...) + 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 and self.geometry is not None: + if self.bbox is None: self.bbox = compute_geometry_bbox(self.geometry) return self diff --git a/stapi-pydantic/tests/test_opportunity.py b/stapi-pydantic/tests/test_opportunity.py index 5c67283..0eaa35f 100644 --- a/stapi-pydantic/tests/test_opportunity.py +++ b/stapi-pydantic/tests/test_opportunity.py @@ -1,5 +1,8 @@ from typing import Any +import pydantic +import pytest +from geojson_pydantic.geometries import Point from stapi_pydantic import ( Opportunity, OpportunityCollection, @@ -114,3 +117,23 @@ def test_opportunity_bbox_3d_geometry() -> None: } 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_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, + } + ) From a294eafe674b7faba4c7b1d34f0afa8c567c2eba Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 23 Jul 2026 20:10:26 -0700 Subject: [PATCH 08/25] feat!: ProductsCollection stapi fields; add cql2_property_names helper --- stapi-pydantic/src/stapi_pydantic/__init__.py | 3 ++- stapi-pydantic/src/stapi_pydantic/filter.py | 19 +++++++++++++++++++ stapi-pydantic/src/stapi_pydantic/product.py | 3 ++- stapi-pydantic/tests/test_filter.py | 17 +++++++++++++++++ stapi-pydantic/tests/test_product.py | 11 +++++++++++ 5 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 stapi-pydantic/tests/test_filter.py create mode 100644 stapi-pydantic/tests/test_product.py diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index cd1ece0..2469651 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -1,7 +1,7 @@ from .conformance import Conformance from .constants import STAPI_VERSION from .datetime_interval import DatetimeInterval -from .filter import CQL2Filter +from .filter import CQL2Filter, cql2_property_names from .json_schema_model import JsonSchemaModel from .opportunity import ( Opportunity, @@ -75,4 +75,5 @@ "SearchParameters", "StoredOrderRequest", "STAPI_VERSION", + "cql2_property_names", ] 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/product.py b/stapi-pydantic/src/stapi_pydantic/product.py index 54b946f..ed0a3ac 100644 --- a/stapi-pydantic/src/stapi_pydantic/product.py +++ b/stapi-pydantic/src/stapi_pydantic/product.py @@ -49,6 +49,7 @@ def with_links(self, links: list[Link] | None = None) -> Self: class ProductsCollection(BaseModel): - type_: Literal["ProductCollection"] = Field(default="ProductCollection", alias="type") + stapi_type: Literal["ProductCollection"] = "ProductCollection" + stapi_version: str = STAPI_VERSION links: list[Link] = Field(default_factory=list) products: list[Product] 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_product.py b/stapi-pydantic/tests/test_product.py new file mode 100644 index 0000000..b706a38 --- /dev/null +++ b/stapi-pydantic/tests/test_product.py @@ -0,0 +1,11 @@ +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 From 26ea2b8352988ea9823e5759a22757c5a928d1f5 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 23 Jul 2026 20:22:49 -0700 Subject: [PATCH 09/25] feat!: stapi-fastapi v0.2.0 request/response shapes and required-queryables enforcement --- stapi-fastapi/src/stapi_fastapi/errors.py | 2 +- .../stapi_fastapi/routers/product_router.py | 11 +++ .../src/stapi_fastapi/routers/root_router.py | 26 ++++--- stapi-fastapi/tests/backends.py | 14 ++-- stapi-fastapi/tests/conftest.py | 24 +++--- stapi-fastapi/tests/test_opportunity_async.py | 34 +++++---- stapi-fastapi/tests/test_order.py | 74 ++++++++++++++++--- stapi-fastapi/tests/test_product.py | 2 +- stapi-pydantic/src/stapi_pydantic/__init__.py | 2 + stapi-pydantic/src/stapi_pydantic/order.py | 7 +- 10 files changed, 139 insertions(+), 57 deletions(-) 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..170ff75 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -27,6 +27,8 @@ OrderPayload, OrderStatus, Prefer, + SearchParameters, + cql2_property_names, ) from stapi_pydantic import ( Product as ProductPydantic, @@ -269,6 +271,7 @@ async def search_opportunities_sync( 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, @@ -309,6 +312,7 @@ async def search_opportunities_async( 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)) @@ -355,10 +359,17 @@ def get_product_order_parameters(self) -> JsonSchemaModel: """ return self.product.order_parameters + 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: OrderPayload, 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, diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index c33abc1..15814d9 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, ) @@ -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,7 +374,7 @@ 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)): @@ -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: """ @@ -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): diff --git a/stapi-fastapi/tests/backends.py b/stapi-fastapi/tests/backends.py index f902fb1..c8680b2 100644 --- a/stapi-fastapi/tests/backends.py +++ b/stapi-fastapi/tests/backends.py @@ -15,9 +15,9 @@ Order, OrderPayload, OrderProperties, - OrderSearchParameters, OrderStatus, OrderStatusCode, + StoredOrderRequest, ) @@ -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, @@ -151,7 +149,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..7840e00 100644 --- a/stapi-fastapi/tests/conftest.py +++ b/stapi-fastapi/tests/conftest.py @@ -176,17 +176,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/test_opportunity_async.py b/stapi-fastapi/tests/test_opportunity_async.py index ea34eb1..91582b9 100644 --- a/stapi-fastapi/tests/test_opportunity_async.py +++ b/stapi-fastapi/tests/test_opportunity_async.py @@ -170,7 +170,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 +196,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 +234,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 +295,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 +338,6 @@ def test_get_search_records_pagination( url="/searches/opportunities", method="GET", limit=limit, - target="search_records", + target="records", expected_returns=expected_returns, ) diff --git a/stapi-fastapi/tests/test_order.py b/stapi-fastapi/tests/test_order.py index d33291c..1e27392 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, OrderPayload, 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,7 +27,14 @@ 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 @@ -32,12 +47,14 @@ 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), + 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) @@ -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/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index 2469651..12a70b2 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -28,6 +28,7 @@ OrderSearchParameters, OrderStatus, OrderStatusCode, + OrderStatusCollection, OrderStatuses, StoredOrderRequest, ) @@ -64,6 +65,7 @@ "OrderSearchParameters", "OrderStatus", "OrderStatusCode", + "OrderStatusCollection", "OrderStatuses", "Prefer", "Product", diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index 34fd88c..7989ed1 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -89,11 +89,16 @@ def new( T = TypeVar("T", bound=OrderStatus) -class OrderStatuses(BaseModel, Generic[T]): +class OrderStatusCollection(BaseModel, Generic[T]): + stapi_type: Literal["OrderStatusCollection"] = "OrderStatusCollection" + stapi_version: str = STAPI_VERSION statuses: list[T] links: list[Link] = Field(default_factory=list) +OrderStatuses = OrderStatusCollection + + OrderSearchParameters = SearchParameters From befc0c688125ba332432dcff65ab52886a8f5360 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 23 Jul 2026 20:32:50 -0700 Subject: [PATCH 10/25] fix!: match opportunities-async conformance URI; adopt v0.2.0 request shapes --- pystapi-client/src/pystapi_client/client.py | 14 ++++++++------ pystapi-client/src/pystapi_client/conformance.py | 2 +- pystapi-client/tests/fixtures/landing_page.json | 4 +++- pystapi-client/tests/fixtures/products.json | 6 ++++++ pystapi-client/tests/test_client.py | 6 ++++++ 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/pystapi-client/src/pystapi_client/client.py b/pystapi-client/src/pystapi_client/client.py index d38c452..a337946 100644 --- a/pystapi-client/src/pystapi_client/client.py +++ b/pystapi-client/src/pystapi_client/client.py @@ -318,12 +318,14 @@ def get_product_opportunities( opportunity_parameters = OpportunityPayload.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, } ) diff --git a/pystapi-client/src/pystapi_client/conformance.py b/pystapi-client/src/pystapi_client/conformance.py index f936173..c9fc002 100644 --- a/pystapi-client/src/pystapi_client/conformance.py +++ b/pystapi-client/src/pystapi_client/conformance.py @@ -8,7 +8,7 @@ class ConformanceClasses(Enum): # defined conformance classes regexes CORE = "/core" OPPORTUNITIES = "/opportunities" - ASYNC_OPPORTUNITIES = "/async-opportunities" + ASYNC_OPPORTUNITIES = "/opportunities-async" @classmethod def get_by_name(cls, name: str) -> "ConformanceClasses": diff --git a/pystapi-client/tests/fixtures/landing_page.json b/pystapi-client/tests/fixtures/landing_page.json index 5c4a27e..c09806b 100644 --- a/pystapi-client/tests/fixtures/landing_page.json +++ b/pystapi-client/tests/fixtures/landing_page.json @@ -3,7 +3,9 @@ "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://stapi.example.com/v0.2.0/core", + "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" ], diff --git a/pystapi-client/tests/fixtures/products.json b/pystapi-client/tests/fixtures/products.json index 5f216b0..e616e90 100644 --- a/pystapi-client/tests/fixtures/products.json +++ b/pystapi-client/tests/fixtures/products.json @@ -1,7 +1,11 @@ { + "stapi_type": "ProductCollection", + "stapi_version": "0.2.0", "products": [ { "type": "Collection", + "stapi_type": "Product", + "stapi_version": "0.2.0", "id": "multispectral", "title": "Multispectral", "description": "Full color EO image", @@ -103,6 +107,8 @@ }, { "type": "Collection", + "stapi_type": "Product", + "stapi_version": "0.2.0", "id": "spotlight", "title": "Spotlight", "description": "SAR Spotlight frame", diff --git a/pystapi-client/tests/test_client.py b/pystapi-client/tests/test_client.py index ea39e5f..6d375d5 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,8 @@ 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) From d64e8642d53b166d75d1790e2c1845f55d346803 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 23 Jul 2026 20:39:48 -0700 Subject: [PATCH 11/25] fix: end-anchor conformance URI patterns to prevent suffix false positives ConformanceClasses.pattern built a regex without an end anchor, and callers used re.match (start-anchored only). This let OPPORTUNITIES ("/opportunities") falsely match URIs like ".../opportunities-async", so a client could report sync-opportunities support against an async-only server. Adding a trailing "$" to the pattern fixes it. --- pystapi-client/src/pystapi_client/conformance.py | 2 +- pystapi-client/tests/test_client.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/pystapi-client/src/pystapi_client/conformance.py b/pystapi-client/src/pystapi_client/conformance.py index c9fc002..2edb546 100644 --- a/pystapi-client/src/pystapi_client/conformance.py +++ b/pystapi-client/src/pystapi_client/conformance.py @@ -29,4 +29,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)}$") diff --git a/pystapi-client/tests/test_client.py b/pystapi-client/tests/test_client.py index 6d375d5..4b9b1b5 100644 --- a/pystapi-client/tests/test_client.py +++ b/pystapi-client/tests/test_client.py @@ -29,3 +29,9 @@ def test_pagination(api: MockRouter) -> None: 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") From 62cdb48ac183e7d165d695680e01a0208d97f2dc Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 23 Jul 2026 20:45:16 -0700 Subject: [PATCH 12/25] chore!: bump packages for STAPI v0.2.0; add openapi export script Co-Authored-By: Claude Fable 5 --- pystapi-client/pyproject.toml | 2 +- scripts/export-openapi | 20 ++++++++++++++++++++ stapi-fastapi/pyproject.toml | 2 +- stapi-pydantic/pyproject.toml | 2 +- uv.lock | 6 +++--- 5 files changed, 26 insertions(+), 6 deletions(-) create mode 100755 scripts/export-openapi 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/scripts/export-openapi b/scripts/export-openapi new file mode 100755 index 0000000..d8192ee --- /dev/null +++ b/scripts/export-openapi @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 + +"""Export the reference application's OpenAPI document as YAML. + +Usage: + + scripts/export-openapi > /path/to/spec/openapi.yaml +""" + +import sys +from pathlib import Path + +import yaml + +root = Path(__file__).parents[1] +sys.path.insert(0, str(root / "stapi-fastapi")) + +from tests.application import app # noqa: E402 + +sys.stdout.write(yaml.safe_dump(app.openapi(), sort_keys=True)) 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-pydantic/pyproject.toml b/stapi-pydantic/pyproject.toml index 8fc6e50..9830e1c 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 = [ diff --git a/uv.lock b/uv.lock index 56e280c..36f26a9 100644 --- a/uv.lock +++ b/uv.lock @@ -1804,7 +1804,7 @@ docs = [ [[package]] name = "pystapi-client" -version = "0.0.1" +version = "0.0.2" source = { editable = "pystapi-client" } dependencies = [ { name = "click" }, @@ -2573,7 +2573,7 @@ wheels = [ [[package]] name = "stapi-fastapi" -version = "0.8.0" +version = "0.9.0" source = { editable = "stapi-fastapi" } dependencies = [ { name = "fastapi" }, @@ -2616,7 +2616,7 @@ dev = [ [[package]] name = "stapi-pydantic" -version = "0.1.0" +version = "0.2.0" source = { editable = "stapi-pydantic" } dependencies = [ { name = "cql2" }, From 100ff0b499c1459c03dd03865bd0b5e8afd2c986 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 23 Jul 2026 20:51:40 -0700 Subject: [PATCH 13/25] fix: wire search record statuses into the reference application The reference app's RootRouter construction omitted get_opportunity_search_record_statuses, so the exported OpenAPI spec was missing the /searches/opportunities/{search_record_id}/statuses endpoint and OpportunitySearchStatusCollection schema. The mock backend already existed and was used by the conftest.py fixture router; this wires the same mock into the application used for OpenAPI export so the reference app exercises the full conformance surface. Co-Authored-By: Claude Fable 5 --- stapi-fastapi/tests/application.py | 2 ++ 1 file changed, 2 insertions(+) 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) From 2fc845259617dd397d8befb52877901c529335c0 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 23 Jul 2026 20:57:23 -0700 Subject: [PATCH 14/25] feat!: remove pre-0.2.0 compatibility aliases Removes the OrderPayload, OpportunityPayload, OrderSearchParameters, OrderStatuses, and OpportunitySearchRecords aliases that scaffolded the v0.2.0 migration. Only the spec-aligned names remain: OrderRequest, OpportunityRequest, SearchParameters, OrderStatusCollection, and OpportunitySearchRecordCollection. Breaking change, accepted for this release. Co-Authored-By: Claude Fable 5 --- pystapi-client/src/pystapi_client/client.py | 8 ++++---- .../stapi_fastapi/backends/product_backend.py | 16 +++++++-------- .../stapi_fastapi/routers/product_router.py | 20 +++++++++---------- stapi-fastapi/tests/backends.py | 10 +++++----- stapi-fastapi/tests/test_order.py | 8 ++++---- stapi-pydantic/src/stapi_pydantic/__init__.py | 10 ---------- .../src/stapi_pydantic/opportunity.py | 6 ------ stapi-pydantic/src/stapi_pydantic/order.py | 9 --------- stapi-pydantic/tests/test_opportunity.py | 5 ----- stapi-pydantic/tests/test_order.py | 5 ----- .../tests/test_search_parameters.py | 6 +----- 11 files changed, 32 insertions(+), 71 deletions(-) diff --git a/pystapi-client/src/pystapi_client/client.py b/pystapi-client/src/pystapi_client/client.py index a337946..7e20fe3 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, ) @@ -316,7 +316,7 @@ 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( { "search_parameters": { "datetime": ( @@ -350,7 +350,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/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/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index 170ff75..f51ccba 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -21,10 +21,10 @@ JsonSchemaModel, Link, OpportunityCollection, - OpportunityPayload, + OpportunityRequest, OpportunitySearchRecord, Order, - OrderPayload, + OrderRequest, OrderStatus, Prefer, SearchParameters, @@ -146,13 +146,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 ] @@ -235,7 +235,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), @@ -266,7 +266,7 @@ async def search_opportunities( async def search_opportunities_sync( self, - search: OpportunityPayload, + search: OpportunityRequest, request: Request, response: Response, prefer: Prefer | None, @@ -308,7 +308,7 @@ async def search_opportunities_sync( async def search_opportunities_async( self, - search: OpportunityPayload, + search: OpportunityRequest, request: Request, prefer: Prefer | None, ) -> JSONResponse: @@ -365,7 +365,7 @@ def validate_required_queryables(self, search_parameters: SearchParameters) -> N if missing: raise QueryablesError(f"filter must include predicates for required queryables: {sorted(missing)}") - async def create_order(self, payload: OrderPayload, request: Request, response: Response) -> Order: # type: ignore + async def create_order(self, payload: OrderRequest, request: Request, response: Response) -> Order: # type: ignore """ Create a new order. """ @@ -394,7 +394,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", @@ -403,7 +403,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/tests/backends.py b/stapi-fastapi/tests/backends.py index c8680b2..16e3429 100644 --- a/stapi-fastapi/tests/backends.py +++ b/stapi-fastapi/tests/backends.py @@ -8,13 +8,13 @@ from stapi_pydantic import ( Opportunity, OpportunityCollection, - OpportunityPayload, + OpportunityRequest, OpportunitySearchRecord, OpportunitySearchStatus, OpportunitySearchStatusCode, Order, - OrderPayload, OrderProperties, + 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. """ @@ -117,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, @@ -138,7 +138,7 @@ async def mock_search_opportunities( async def mock_search_opportunities_async( product_router: ProductRouter, - search: OpportunityPayload, + search: OpportunityRequest, request: Request, ) -> ResultE[OpportunitySearchRecord]: try: diff --git a/stapi-fastapi/tests/test_order.py b/stapi-fastapi/tests/test_order.py index 1e27392..e57ec92 100644 --- a/stapi-fastapi/tests/test_order.py +++ b/stapi-fastapi/tests/test_order.py @@ -6,7 +6,7 @@ from geojson_pydantic import Point from geojson_pydantic.types import Position2D from httpx import Response -from stapi_pydantic import STAPI_VERSION, Order, OrderPayload, OrderStatus, OrderStatusCode, SearchParameters +from stapi_pydantic import STAPI_VERSION, Order, OrderRequest, OrderStatus, OrderStatusCode, SearchParameters from .shared import MyOrderParameters, find_link, pagination_tester @@ -38,7 +38,7 @@ def test_empty_order(stapi_client: TestClient): @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"), @@ -46,7 +46,7 @@ def create_order_payloads() -> list[OrderPayload]: ] payloads = [] for start, end in datetimes: - payload = OrderPayload( + payload = OrderRequest( search_parameters=SearchParameters( geometry=Point(type="Point", coordinates=Position2D(longitude=14.4, latitude=56.5)), datetime=( @@ -65,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", diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index 12a70b2..8af1f67 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -6,12 +6,10 @@ from .opportunity import ( Opportunity, OpportunityCollection, - OpportunityPayload, OpportunityProperties, OpportunityRequest, OpportunitySearchRecord, OpportunitySearchRecordCollection, - OpportunitySearchRecords, OpportunitySearchStatus, OpportunitySearchStatusCode, OpportunitySearchStatusCollection, @@ -22,14 +20,11 @@ Order, OrderCollection, OrderParameters, - OrderPayload, OrderProperties, OrderRequest, - OrderSearchParameters, OrderStatus, OrderStatusCode, OrderStatusCollection, - OrderStatuses, StoredOrderRequest, ) from .product import Product, ProductsCollection, Provider, ProviderRole @@ -47,26 +42,21 @@ "Link", "Opportunity", "OpportunityCollection", - "OpportunityPayload", "OpportunityProperties", "OpportunityRequest", "OpportunitySearchRecord", "OpportunitySearchRecordCollection", - "OpportunitySearchRecords", "OpportunitySearchStatus", "OpportunitySearchStatusCode", "OpportunitySearchStatusCollection", "Order", "OrderCollection", "OrderParameters", - "OrderPayload", "OrderProperties", "OrderRequest", - "OrderSearchParameters", "OrderStatus", "OrderStatusCode", "OrderStatusCollection", - "OrderStatuses", "Prefer", "Product", "ProductsCollection", diff --git a/stapi-pydantic/src/stapi_pydantic/opportunity.py b/stapi-pydantic/src/stapi_pydantic/opportunity.py index 2cc7a2c..5c27208 100644 --- a/stapi-pydantic/src/stapi_pydantic/opportunity.py +++ b/stapi-pydantic/src/stapi_pydantic/opportunity.py @@ -43,9 +43,6 @@ def body(self) -> dict[str, Any]: return self.model_dump(mode="json") -OpportunityPayload = OpportunityRequest - - G = TypeVar("G", bound=Geometry) P = TypeVar("P", bound=OpportunityProperties) @@ -106,9 +103,6 @@ class OpportunitySearchRecordCollection(BaseModel): links: list[Link] = Field(default_factory=list) -OpportunitySearchRecords = OpportunitySearchRecordCollection - - class OpportunitySearchStatusCollection(BaseModel): stapi_type: Literal["OpportunitySearchStatusCollection"] = "OpportunitySearchStatusCollection" stapi_version: str = STAPI_VERSION diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index 7989ed1..1c05b85 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -96,12 +96,6 @@ class OrderStatusCollection(BaseModel, Generic[T]): links: list[Link] = Field(default_factory=list) -OrderStatuses = OrderStatusCollection - - -OrderSearchParameters = SearchParameters - - class StoredOrderRequest(BaseModel): """Stored form of an Order Request within Order properties. @@ -191,6 +185,3 @@ class OrderRequest(BaseModel, Generic[ORP]): order_parameters: ORP = Field(default_factory=dict, validate_default=True) model_config = ConfigDict(strict=True) - - -OrderPayload = OrderRequest diff --git a/stapi-pydantic/tests/test_opportunity.py b/stapi-pydantic/tests/test_opportunity.py index 0eaa35f..2153ebf 100644 --- a/stapi-pydantic/tests/test_opportunity.py +++ b/stapi-pydantic/tests/test_opportunity.py @@ -6,7 +6,6 @@ from stapi_pydantic import ( Opportunity, OpportunityCollection, - OpportunityPayload, OpportunityProperties, OpportunityRequest, OpportunitySearchRecord, @@ -50,10 +49,6 @@ def test_opportunity_request_body_includes_pagination() -> None: assert "search_parameters" in body -def test_opportunity_payload_alias() -> None: - assert OpportunityPayload is OpportunityRequest - - SEARCH_RECORD_DICT = { "id": "search-1", "product_id": "umbra_spotlight", diff --git a/stapi-pydantic/tests/test_order.py b/stapi-pydantic/tests/test_order.py index f4ab073..00bba57 100644 --- a/stapi-pydantic/tests/test_order.py +++ b/stapi-pydantic/tests/test_order.py @@ -8,7 +8,6 @@ Order, OrderCollection, OrderParameters, - OrderPayload, OrderRequest, OrderStatus, OrderStatusCode, @@ -50,10 +49,6 @@ def test_order_request_omitted_order_parameters_fails_when_required() -> None: OrderRequest[RequiredParams].model_validate({"search_parameters": SEARCH_PARAMS}) -def test_order_payload_alias() -> None: - assert OrderPayload is OrderRequest - - ORDER_DICT: dict[str, Any] = { "id": "order-1", "type": "Feature", diff --git a/stapi-pydantic/tests/test_search_parameters.py b/stapi-pydantic/tests/test_search_parameters.py index e220135..6a296a3 100644 --- a/stapi-pydantic/tests/test_search_parameters.py +++ b/stapi-pydantic/tests/test_search_parameters.py @@ -1,4 +1,4 @@ -from stapi_pydantic import STAPI_VERSION, OrderSearchParameters, SearchParameters +from stapi_pydantic import STAPI_VERSION, SearchParameters def test_stapi_version_is_0_2_0() -> None: @@ -24,7 +24,3 @@ def test_search_parameters_with_filter() -> None: } ) assert sp.filter is not None - - -def test_order_search_parameters_alias() -> None: - assert OrderSearchParameters is SearchParameters From 3490f01522b9ae31192f2b6cb2d02a84756a3a8c Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 23 Jul 2026 21:13:44 -0700 Subject: [PATCH 15/25] fix: dedicated generic app for openapi export; round-trip and rejection tests Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RhnGNojViYvYDevhtz999a --- pyproject.toml | 1 + scripts/export-openapi | 63 ++++++++++++++- scripts/openapi_app.py | 79 +++++++++++++++++++ stapi-fastapi/tests/test_opportunity.py | 18 +++++ stapi-fastapi/tests/test_opportunity_async.py | 19 +++++ stapi-pydantic/tests/test_opportunity.py | 8 ++ uv.lock | 2 + 7 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 scripts/openapi_app.py diff --git a/pyproject.toml b/pyproject.toml index ae04ce5..617a976 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dev = [ "pre-commit>=4.2.0", "pre-commit-hooks>=5.0.0", "pygithub>=2.6.1", + "pyyaml>=6.0", ] docs = [ "mkdocs-material>=9.6.11", diff --git a/scripts/export-openapi b/scripts/export-openapi index d8192ee..b7fb505 100755 --- a/scripts/export-openapi +++ b/scripts/export-openapi @@ -1,6 +1,11 @@ -#!/usr/bin/env python3 +#!/usr/bin/env -S uv run python -"""Export the reference application's OpenAPI document as YAML. +"""Export the STAPI OpenAPI document as YAML. + +The document is generated from a dedicated, generic export application (see +``scripts/openapi_app.py``) rather than the test suite, so the artifact +describes the API generically. The single ``example`` product's paths are +post-processed into templated ``/products/{productId}/...`` form. Usage: @@ -8,13 +13,63 @@ Usage: """ import sys +from copy import deepcopy from pathlib import Path +from typing import Any import yaml root = Path(__file__).parents[1] sys.path.insert(0, str(root / "stapi-fastapi")) +sys.path.insert(0, str(root / "scripts")) + +from openapi_app import app # noqa: E402 + +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"} + + +def templatize_product_paths(openapi: dict[str, Any]) -> dict[str, Any]: + """Rewrite the concrete ``example`` product paths into templated form. + + ``/products/example`` -> ``/products/{productId}`` and + ``/products/example/...`` -> ``/products/{productId}/...``, injecting a + ``productId`` path parameter into each operation. + """ + paths: dict[str, Any] = openapi["paths"] + new_paths: dict[str, Any] = {} + + for path, path_item in paths.items(): + if path == "/products/example": + new_path = "/products/{productId}" + elif path.startswith("/products/example/"): + new_path = "/products/{productId}/" + path[len("/products/example/") :] + 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 main() -> None: + openapi = templatize_product_paths(app.openapi()) + sys.stdout.write(yaml.safe_dump(openapi, sort_keys=True)) -from tests.application import app # noqa: E402 -sys.stdout.write(yaml.safe_dump(app.openapi(), sort_keys=True)) +if __name__ == "__main__": + main() diff --git a/scripts/openapi_app.py b/scripts/openapi_app.py new file mode 100644 index 0000000..1555b25 --- /dev/null +++ b/scripts/openapi_app.py @@ -0,0 +1,79 @@ +"""A minimal, generic FastAPI app dedicated to OpenAPI spec export. + +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. +""" + +from typing import Any, NoReturn + +from fastapi import FastAPI +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, +) + + +async def _not_implemented(*args: Any, **kwargs: Any) -> NoReturn: + """Stub backend. Never called during schema export.""" + raise NotImplementedError + + +provider = Provider( + name="Example Provider", + description="An example data provider", + roles=[ProviderRole.producer], + url="https://example.com", + conformsTo=[PRODUCT.geojson_point], +) + +example_product = Product( + id="example", + title="Example Product", + description="An example product", + 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=[ + API.core, + API.order_statuses, + API.searches_opportunity, + API.searches_opportunity_statuses, + ], +) +root_router.add_product(example_product) + +app: FastAPI = FastAPI(title="STAPI", version=STAPI_VERSION) +app.include_router(root_router, prefix="") 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 91582b9..2fbc9a3 100644 --- a/stapi-fastapi/tests/test_opportunity_async.py +++ b/stapi-fastapi/tests/test_opportunity_async.py @@ -341,3 +341,22 @@ def test_get_search_records_pagination( 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-pydantic/tests/test_opportunity.py b/stapi-pydantic/tests/test_opportunity.py index 2153ebf..9ffeb7f 100644 --- a/stapi-pydantic/tests/test_opportunity.py +++ b/stapi-pydantic/tests/test_opportunity.py @@ -12,6 +12,8 @@ OpportunitySearchRecordCollection, OpportunitySearchStatus, OpportunitySearchStatusCollection, + OrderParameters, + OrderRequest, ) SEARCH_PARAMS = { @@ -39,6 +41,12 @@ def test_opportunity_request_search_body_is_order_request_shaped() -> None: 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} diff --git a/uv.lock b/uv.lock index 36f26a9..8921cf5 100644 --- a/uv.lock +++ b/uv.lock @@ -1773,6 +1773,7 @@ dev = [ { name = "pre-commit-hooks" }, { name = "pygithub" }, { name = "pymarkdownlnt" }, + { name = "pyyaml" }, { name = "ruff" }, ] docs = [ @@ -1795,6 +1796,7 @@ 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" }, ] docs = [ From 51e91f8d3523f373852bf807313f46571c75731b Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 23 Jul 2026 21:33:15 -0700 Subject: [PATCH 16/25] Enrich the exported OpenAPI document metadata Harvested from unmerged PR #68 (pystapi-schema-generator): the spec intro as the info description, a contact block, per-tag descriptions with externalDocs links into the spec documents, and top-level externalDocs pointing at the rendered documentation site. Links are updated for the current spec repo layout and tag names match the actual router tags. Note openapi_extra is a path-operation parameter, not an application one, so top-level externalDocs is injected via an openapi wrapper. Also drop an inert conformsTo kwarg mistakenly passed to Provider. Co-Authored-By: Claude Fable 5 --- scripts/openapi_app.py | 84 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 5 deletions(-) diff --git a/scripts/openapi_app.py b/scripts/openapi_app.py index 1555b25..4267d6b 100644 --- a/scripts/openapi_app.py +++ b/scripts/openapi_app.py @@ -35,16 +35,19 @@ async def _not_implemented(*args: Any, **kwargs: Any) -> NoReturn: provider = Provider( name="Example Provider", - description="An example data provider", + description="Example provider for demonstration purposes", roles=[ProviderRole.producer], - url="https://example.com", - conformsTo=[PRODUCT.geojson_point], + url="https://example.com/provider", ) example_product = Product( id="example", title="Example Product", - description="An 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], @@ -75,5 +78,76 @@ async def _not_implemented(*args: Any, **kwargs: Any) -> NoReturn: ) root_router.add_product(example_product) -app: FastAPI = FastAPI(title="STAPI", version=STAPI_VERSION) +_SPEC_DOCS = "https://github.com/stapi-spec/stapi-spec/blob/main/docs" + +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] From 82e7d039829c2de24eb4f2152cfaaed8f3132cdb Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 23 Jul 2026 22:01:54 -0700 Subject: [PATCH 17/25] feat: promote OpenAPI export into stapi-fastapi as reference_app Move the generic reference FastAPI app and OpenAPI post-processing logic from scripts/openapi_app.py + scripts/export-openapi into a new stapi_fastapi.reference_app module, exposing create_reference_app(), export_openapi(), and main(). Add an optional "export" extra (pyyaml) and a stapi-fastapi-export-openapi console script so the tool ships with the package. scripts/export-openapi is now a thin shim delegating to the packaged main(). Add tests/test_reference_app.py asserting export invariants (path inventory, no leaked example-product paths, productId param injection, info/externalDocs, key component schemas, determinism) so these run under the normal test/type gates. types-pyyaml added to the root dev group to keep mypy clean on the new module. Verified the export is byte-identical to the committed spec/openapi.yaml before and after. Co-Authored-By: Claude Fable 5 --- pyproject.toml | 1 + scripts/export-openapi | 69 +----- scripts/openapi_app.py | 153 ------------ stapi-fastapi/pyproject.toml | 6 + .../src/stapi_fastapi/reference_app.py | 222 ++++++++++++++++++ stapi-fastapi/tests/test_reference_app.py | 65 +++++ uv.lock | 18 ++ 7 files changed, 317 insertions(+), 217 deletions(-) delete mode 100644 scripts/openapi_app.py create mode 100644 stapi-fastapi/src/stapi_fastapi/reference_app.py create mode 100644 stapi-fastapi/tests/test_reference_app.py diff --git a/pyproject.toml b/pyproject.toml index 617a976..c64bc2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dev = [ "pre-commit-hooks>=5.0.0", "pygithub>=2.6.1", "pyyaml>=6.0", + "types-pyyaml>=6.0", ] docs = [ "mkdocs-material>=9.6.11", diff --git a/scripts/export-openapi b/scripts/export-openapi index b7fb505..d8942c1 100755 --- a/scripts/export-openapi +++ b/scripts/export-openapi @@ -2,74 +2,15 @@ """Export the STAPI OpenAPI document as YAML. -The document is generated from a dedicated, generic export application (see -``scripts/openapi_app.py``) rather than the test suite, so the artifact -describes the API generically. The single ``example`` product's paths are -post-processed into templated ``/products/{productId}/...`` form. +This is a thin shim that delegates to the packaged reference app export tool +(``stapi_fastapi.reference_app.main``), which is also installed as the +``stapi-fastapi-export-openapi`` console script. Usage: scripts/export-openapi > /path/to/spec/openapi.yaml """ -import sys -from copy import deepcopy -from pathlib import Path -from typing import Any +from stapi_fastapi.reference_app import main -import yaml - -root = Path(__file__).parents[1] -sys.path.insert(0, str(root / "stapi-fastapi")) -sys.path.insert(0, str(root / "scripts")) - -from openapi_app import app # noqa: E402 - -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"} - - -def templatize_product_paths(openapi: dict[str, Any]) -> dict[str, Any]: - """Rewrite the concrete ``example`` product paths into templated form. - - ``/products/example`` -> ``/products/{productId}`` and - ``/products/example/...`` -> ``/products/{productId}/...``, injecting a - ``productId`` path parameter into each operation. - """ - paths: dict[str, Any] = openapi["paths"] - new_paths: dict[str, Any] = {} - - for path, path_item in paths.items(): - if path == "/products/example": - new_path = "/products/{productId}" - elif path.startswith("/products/example/"): - new_path = "/products/{productId}/" + path[len("/products/example/") :] - 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 main() -> None: - openapi = templatize_product_paths(app.openapi()) - sys.stdout.write(yaml.safe_dump(openapi, sort_keys=True)) - - -if __name__ == "__main__": - main() +main() diff --git a/scripts/openapi_app.py b/scripts/openapi_app.py deleted file mode 100644 index 4267d6b..0000000 --- a/scripts/openapi_app.py +++ /dev/null @@ -1,153 +0,0 @@ -"""A minimal, generic FastAPI app dedicated to OpenAPI spec export. - -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. -""" - -from typing import Any, NoReturn - -from fastapi import FastAPI -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, -) - - -async def _not_implemented(*args: Any, **kwargs: Any) -> NoReturn: - """Stub backend. Never called during schema export.""" - raise NotImplementedError - - -provider = Provider( - name="Example Provider", - description="Example provider for demonstration purposes", - roles=[ProviderRole.producer], - url="https://example.com/provider", -) - -example_product = Product( - id="example", - 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=[ - API.core, - API.order_statuses, - API.searches_opportunity, - API.searches_opportunity_statuses, - ], -) -root_router.add_product(example_product) - -_SPEC_DOCS = "https://github.com/stapi-spec/stapi-spec/blob/main/docs" - -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] diff --git a/stapi-fastapi/pyproject.toml b/stapi-fastapi/pyproject.toml index 54e87f2..d9d702a 100644 --- a/stapi-fastapi/pyproject.toml +++ b/stapi-fastapi/pyproject.toml @@ -24,6 +24,12 @@ dependencies = [ "stapi-pydantic>=0.1.0", ] +[project.optional-dependencies] +export = ["pyyaml>=6.0"] + +[project.scripts] +stapi-fastapi-export-openapi = "stapi_fastapi.reference_app:main" + [dependency-groups] dev = [ "fastapi[standard]>=0.115.0", diff --git a/stapi-fastapi/src/stapi_fastapi/reference_app.py b/stapi-fastapi/src/stapi_fastapi/reference_app.py new file mode 100644 index 0000000..bdc9af1 --- /dev/null +++ b/stapi-fastapi/src/stapi_fastapi/reference_app.py @@ -0,0 +1,222 @@ +"""A minimal, generic FastAPI app dedicated to OpenAPI spec export. + +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. +""" + +from copy import deepcopy +from typing import Any, NoReturn + +from fastapi import FastAPI +from stapi_pydantic import ( + STAPI_VERSION, + OpportunityProperties, + OrderParameters, + Provider, + ProviderRole, + Queryables, +) + +from stapi_fastapi.conformance import API, PRODUCT +from stapi_fastapi.models.product import Product +from stapi_fastapi.routers.root_router import RootRouter + +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" + + +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="example", + 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=[ + API.core, + API.order_statuses, + API.searches_opportunity, + API.searches_opportunity_statuses, + ], + ) + 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 _templatize_product_paths(openapi: dict[str, Any]) -> dict[str, Any]: + """Rewrite the concrete ``example`` product paths into templated form. + + ``/products/example`` -> ``/products/{productId}`` and + ``/products/example/...`` -> ``/products/{productId}/...``, injecting a + ``productId`` path parameter into each operation. + """ + paths: dict[str, Any] = openapi["paths"] + new_paths: dict[str, Any] = {} + + for path, path_item in paths.items(): + if path == "/products/example": + new_path = "/products/{productId}" + elif path.startswith("/products/example/"): + new_path = "/products/{productId}/" + path[len("/products/example/") :] + 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() + return _templatize_product_paths(app.openapi()) + + +def main() -> None: + """Write the exported OpenAPI document as YAML to stdout.""" + try: + import yaml + except ImportError as e: + raise ImportError( + "PyYAML is required to export the OpenAPI document. " + "Install it with the 'export' extra: `pip install stapi-fastapi[export]`." + ) from e + + import sys + + sys.stdout.write(yaml.safe_dump(export_openapi(), sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/stapi-fastapi/tests/test_reference_app.py b/stapi-fastapi/tests/test_reference_app.py new file mode 100644 index 0000000..1bb3ea2 --- /dev/null +++ b/stapi-fastapi/tests/test_reference_app.py @@ -0,0 +1,65 @@ +from typing import Any + +from stapi_fastapi.reference_app import export_openapi +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", +} + + +def test_path_inventory_matches_spec_endpoints() -> None: + assert set(export_openapi()["paths"]) == EXPECTED_PATHS + + +def test_no_concrete_example_product_paths() -> None: + assert "/products/example" not in str(export_openapi()) + + +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/" + + +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 diff --git a/uv.lock b/uv.lock index 8921cf5..71edb16 100644 --- a/uv.lock +++ b/uv.lock @@ -1775,6 +1775,7 @@ dev = [ { name = "pymarkdownlnt" }, { name = "pyyaml" }, { name = "ruff" }, + { name = "types-pyyaml" }, ] docs = [ { name = "mkdocs-material" }, @@ -1798,6 +1799,7 @@ dev = [ { 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" }, @@ -2590,6 +2592,11 @@ dependencies = [ { name = "uvicorn" }, ] +[package.optional-dependencies] +export = [ + { name = "pyyaml" }, +] + [package.dev-dependencies] dev = [ { name = "fastapi", extra = ["standard"] }, @@ -2605,10 +2612,12 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.10" }, { name = "pydantic-settings", specifier = ">=2.2.1" }, { name = "pygeofilter", specifier = ">=0.2" }, + { name = "pyyaml", marker = "extra == 'export'", specifier = ">=6.0" }, { name = "returns", specifier = ">=0.23" }, { name = "stapi-pydantic", editable = "stapi-pydantic" }, { name = "uvicorn", specifier = ">=0.29.0" }, ] +provides-extras = ["export"] [package.metadata.requires-dev] dev = [ @@ -2758,6 +2767,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" From 044297db410221d81ab84a7b81d1ceffcb7d4f4d Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 23 Jul 2026 22:33:40 -0700 Subject: [PATCH 18/25] feat: extract OpenAPI export into pystapi-schema-generator package The framework wheel no longer ships the reference app or a console script. Move the generic reference FastAPI app and OpenAPI export CLI out of stapi-fastapi into a new thin workspace member, pystapi-schema-generator, which realizes the package structure proposed in unmerged PR #68 but wires it against the real stapi_fastapi routers (no forked routers). The package imports pyyaml unconditionally at module scope, since export is its entire purpose, dropping the lazy-import/extra dance stapi-fastapi needed as a general-purpose framework. scripts/export-openapi is now a shim delegating to pystapi_schema_generator.application.main. Wired the new member into the root workspace, dependencies, and mypy file list, and into scripts/run-tests.sh's per-package test loop. Verified the exported openapi.yaml is still byte-identical to the committed stapi-spec artifact. Co-Authored-By: Claude Fable 5 --- pyproject.toml | 7 ++-- pystapi-schema-generator/README.md | 9 +++++ pystapi-schema-generator/pyproject.toml | 25 ++++++++++++++ .../src/pystapi_schema_generator/__init__.py | 7 ++++ .../pystapi_schema_generator/application.py | 22 +++++-------- .../src/pystapi_schema_generator/py.typed | 0 .../tests/test_application.py | 2 +- scripts/export-openapi | 6 ++-- scripts/run-tests.sh | 2 +- stapi-fastapi/pyproject.toml | 6 ---- uv.lock | 33 +++++++++++++++---- 11 files changed, 85 insertions(+), 34 deletions(-) create mode 100644 pystapi-schema-generator/README.md create mode 100644 pystapi-schema-generator/pyproject.toml create mode 100644 pystapi-schema-generator/src/pystapi_schema_generator/__init__.py rename stapi-fastapi/src/stapi_fastapi/reference_app.py => pystapi-schema-generator/src/pystapi_schema_generator/application.py (96%) create mode 100644 pystapi-schema-generator/src/pystapi_schema_generator/py.typed rename stapi-fastapi/tests/test_reference_app.py => pystapi-schema-generator/tests/test_application.py (96%) diff --git a/pyproject.toml b/pyproject.toml index c64bc2c..3b5f131 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "pystapi-validator", "stapi-pydantic", "stapi-fastapi", + "pystapi-schema-generator", ] [dependency-groups] @@ -31,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 @@ -64,7 +66,8 @@ files = [ "pystapi-client/src/pystapi_client/**/*.py", "pystapi-validator/src/pystapi_validator/**/*.py", "stapi-pydantic/src/stapi_pydantic/**/*.py", - "stapi-fastapi/src/stapi_fastapi/**/*.py" + "stapi-fastapi/src/stapi_fastapi/**/*.py", + "pystapi-schema-generator/src/pystapi_schema_generator/**/*.py" ] [[tool.mypy.overrides]] diff --git a/pystapi-schema-generator/README.md b/pystapi-schema-generator/README.md new file mode 100644 index 0000000..5dbb726 --- /dev/null +++ b/pystapi-schema-generator/README.md @@ -0,0 +1,9 @@ +# pystapi-schema-generator + +A minimal reference STAPI application and console script for exporting its OpenAPI document as YAML. + +## Usage + +```bash +pystapi-schema-generator > openapi.yaml +``` diff --git a/pystapi-schema-generator/pyproject.toml b/pystapi-schema-generator/pyproject.toml new file mode 100644 index 0000000..957a43a --- /dev/null +++ b/pystapi-schema-generator/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "pystapi-schema-generator" +version = "0.1.0" +description = "Reference STAPI application and OpenAPI schema export tooling" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "stapi-fastapi", + "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/stapi-fastapi/src/stapi_fastapi/reference_app.py b/pystapi-schema-generator/src/pystapi_schema_generator/application.py similarity index 96% rename from stapi-fastapi/src/stapi_fastapi/reference_app.py rename to pystapi-schema-generator/src/pystapi_schema_generator/application.py index bdc9af1..44d274f 100644 --- a/stapi-fastapi/src/stapi_fastapi/reference_app.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/application.py @@ -1,5 +1,8 @@ """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 @@ -12,10 +15,15 @@ describes the API generically. """ +import sys from copy import deepcopy from typing import Any, NoReturn +import yaml from fastapi import FastAPI +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, @@ -25,10 +33,6 @@ Queryables, ) -from stapi_fastapi.conformance import API, PRODUCT -from stapi_fastapi.models.product import Product -from stapi_fastapi.routers.root_router import RootRouter - PRODUCT_ID_PARAMETER: dict[str, Any] = { "name": "productId", "in": "path", @@ -205,16 +209,6 @@ def export_openapi() -> dict[str, Any]: def main() -> None: """Write the exported OpenAPI document as YAML to stdout.""" - try: - import yaml - except ImportError as e: - raise ImportError( - "PyYAML is required to export the OpenAPI document. " - "Install it with the 'export' extra: `pip install stapi-fastapi[export]`." - ) from e - - import sys - sys.stdout.write(yaml.safe_dump(export_openapi(), sort_keys=True)) 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/stapi-fastapi/tests/test_reference_app.py b/pystapi-schema-generator/tests/test_application.py similarity index 96% rename from stapi-fastapi/tests/test_reference_app.py rename to pystapi-schema-generator/tests/test_application.py index 1bb3ea2..1a9768e 100644 --- a/stapi-fastapi/tests/test_reference_app.py +++ b/pystapi-schema-generator/tests/test_application.py @@ -1,6 +1,6 @@ from typing import Any -from stapi_fastapi.reference_app import export_openapi +from pystapi_schema_generator.application import export_openapi from stapi_pydantic import STAPI_VERSION EXPECTED_PATHS = { diff --git a/scripts/export-openapi b/scripts/export-openapi index d8942c1..753893e 100755 --- a/scripts/export-openapi +++ b/scripts/export-openapi @@ -3,14 +3,14 @@ """Export the STAPI OpenAPI document as YAML. This is a thin shim that delegates to the packaged reference app export tool -(``stapi_fastapi.reference_app.main``), which is also installed as the -``stapi-fastapi-export-openapi`` console script. +(``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 stapi_fastapi.reference_app import main +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 d9d702a..54e87f2 100644 --- a/stapi-fastapi/pyproject.toml +++ b/stapi-fastapi/pyproject.toml @@ -24,12 +24,6 @@ dependencies = [ "stapi-pydantic>=0.1.0", ] -[project.optional-dependencies] -export = ["pyyaml>=6.0"] - -[project.scripts] -stapi-fastapi-export-openapi = "stapi_fastapi.reference_app:main" - [dependency-groups] dev = [ "fastapi[standard]>=0.115.0", diff --git a/uv.lock b/uv.lock index 71edb16..d715259 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" }, @@ -1785,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" }, @@ -1839,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" @@ -2592,11 +2618,6 @@ dependencies = [ { name = "uvicorn" }, ] -[package.optional-dependencies] -export = [ - { name = "pyyaml" }, -] - [package.dev-dependencies] dev = [ { name = "fastapi", extra = ["standard"] }, @@ -2612,12 +2633,10 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.10" }, { name = "pydantic-settings", specifier = ">=2.2.1" }, { name = "pygeofilter", specifier = ">=0.2" }, - { name = "pyyaml", marker = "extra == 'export'", specifier = ">=6.0" }, { name = "returns", specifier = ">=0.23" }, { name = "stapi-pydantic", editable = "stapi-pydantic" }, { name = "uvicorn", specifier = ">=0.29.0" }, ] -provides-extras = ["export"] [package.metadata.requires-dev] dev = [ From 2d89f79531d2a4b0a7cc0cad8619b32e6a02aac2 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Fri, 24 Jul 2026 10:12:06 -0700 Subject: [PATCH 19/25] style: format stapi-pydantic tests with ruff Co-Authored-By: Claude Fable 5 --- stapi-pydantic/tests/test_opportunity.py | 8 ++------ stapi-pydantic/tests/test_order.py | 4 +--- stapi-pydantic/tests/test_product.py | 4 +--- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/stapi-pydantic/tests/test_opportunity.py b/stapi-pydantic/tests/test_opportunity.py index 9ffeb7f..77e451b 100644 --- a/stapi-pydantic/tests/test_opportunity.py +++ b/stapi-pydantic/tests/test_opportunity.py @@ -48,9 +48,7 @@ def test_search_body_is_valid_order_request() -> None: def test_opportunity_request_body_includes_pagination() -> None: - req = OpportunityRequest.model_validate( - {"search_parameters": SEARCH_PARAMS, "next": "abc", "limit": 5} - ) + req = OpportunityRequest.model_validate({"search_parameters": SEARCH_PARAMS, "next": "abc", "limit": 5}) body = req.body() assert body["next"] == "abc" assert body["limit"] == 5 @@ -78,9 +76,7 @@ def test_opportunity_search_record_request_field() -> None: def test_opportunity_search_record_collection() -> None: - collection = OpportunitySearchRecordCollection( - records=[OpportunitySearchRecord.model_validate(SEARCH_RECORD_DICT)] - ) + 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 diff --git a/stapi-pydantic/tests/test_order.py b/stapi-pydantic/tests/test_order.py index 00bba57..146afca 100644 --- a/stapi-pydantic/tests/test_order.py +++ b/stapi-pydantic/tests/test_order.py @@ -33,9 +33,7 @@ class RequiredParams(OrderParameters): def test_order_request_shape() -> None: - req = OrderRequest[OrderParameters].model_validate( - {"search_parameters": SEARCH_PARAMS, "order_parameters": {}} - ) + req = OrderRequest[OrderParameters].model_validate({"search_parameters": SEARCH_PARAMS, "order_parameters": {}}) assert req.search_parameters.filter is None diff --git a/stapi-pydantic/tests/test_product.py b/stapi-pydantic/tests/test_product.py index b706a38..55215e0 100644 --- a/stapi-pydantic/tests/test_product.py +++ b/stapi-pydantic/tests/test_product.py @@ -2,9 +2,7 @@ def test_products_collection_stapi_fields() -> None: - collection = ProductsCollection( - products=[Product(id="p1", license="proprietary", description="d")] - ) + 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" From 5557dfd0241086fc2744195f570032a24c1cee00 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Fri, 24 Jul 2026 10:14:00 -0700 Subject: [PATCH 20/25] feat: support singly-open datetime intervals in SearchParameters The spec's Search Parameters Object datetime allows open ends via '..' or an empty string, with only singly-open intervals permitted. Adds OpenDatetimeInterval; OpportunityProperties keeps the closed DatetimeInterval since an opportunity window is concrete. Co-Authored-By: Claude Fable 5 --- stapi-pydantic/src/stapi_pydantic/__init__.py | 3 +- .../src/stapi_pydantic/datetime_interval.py | 47 +++++++++++++++++++ .../src/stapi_pydantic/search_parameters.py | 4 +- .../tests/test_search_parameters.py | 31 ++++++++++++ 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index 8af1f67..283ebfc 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -1,6 +1,6 @@ from .conformance import Conformance from .constants import STAPI_VERSION -from .datetime_interval import DatetimeInterval +from .datetime_interval import DatetimeInterval, OpenDatetimeInterval from .filter import CQL2Filter, cql2_property_names from .json_schema_model import JsonSchemaModel from .opportunity import ( @@ -40,6 +40,7 @@ "DatetimeInterval", "JsonSchemaModel", "Link", + "OpenDatetimeInterval", "Opportunity", "OpportunityCollection", "OpportunityProperties", 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/search_parameters.py b/stapi-pydantic/src/stapi_pydantic/search_parameters.py index f7ead4c..65e377d 100644 --- a/stapi-pydantic/src/stapi_pydantic/search_parameters.py +++ b/stapi-pydantic/src/stapi_pydantic/search_parameters.py @@ -1,7 +1,7 @@ from geojson_pydantic.geometries import Geometry from pydantic import BaseModel -from .datetime_interval import DatetimeInterval +from .datetime_interval import OpenDatetimeInterval from .filter import CQL2Filter @@ -13,6 +13,6 @@ class SearchParameters(BaseModel): See stapi-spec docs/spec/search-parameters/README.md. """ - datetime: DatetimeInterval + datetime: OpenDatetimeInterval geometry: Geometry filter: CQL2Filter | None = None # type: ignore [type-arg] diff --git a/stapi-pydantic/tests/test_search_parameters.py b/stapi-pydantic/tests/test_search_parameters.py index 6a296a3..414068c 100644 --- a/stapi-pydantic/tests/test_search_parameters.py +++ b/stapi-pydantic/tests/test_search_parameters.py @@ -1,5 +1,9 @@ +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" @@ -15,6 +19,33 @@ def test_search_parameters_minimal() -> None: 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( { From 3be379ebe045c8233398d6dc5735f1fad2998239 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Fri, 24 Jul 2026 10:17:19 -0700 Subject: [PATCH 21/25] feat!: allow provider extension status codes; make code sets constrainable Spec frames statuses as extensible: providers may support additional statuses through extensions. status_code is now enum-or-string (known codes still validate to the enum), and OrderStatus / OpportunitySearchStatus are generic so implementations can constrain the accepted set with their own StrEnum, e.g. OrderStatus[MyCodes]. Co-Authored-By: Claude Fable 5 --- stapi-pydantic/pyproject.toml | 2 +- .../src/stapi_pydantic/opportunity.py | 18 +++++++++-- stapi-pydantic/src/stapi_pydantic/order.py | 21 +++++++++---- stapi-pydantic/tests/test_opportunity.py | 18 +++++++++++ stapi-pydantic/tests/test_order.py | 30 +++++++++++++++++++ uv.lock | 2 ++ 6 files changed, 82 insertions(+), 9 deletions(-) diff --git a/stapi-pydantic/pyproject.toml b/stapi-pydantic/pyproject.toml index 9830e1c..50f4c46 100644 --- a/stapi-pydantic/pyproject.toml +++ b/stapi-pydantic/pyproject.toml @@ -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/opportunity.py b/stapi-pydantic/src/stapi_pydantic/opportunity.py index 5c27208..493b648 100644 --- a/stapi-pydantic/src/stapi_pydantic/opportunity.py +++ b/stapi-pydantic/src/stapi_pydantic/opportunity.py @@ -1,11 +1,12 @@ from __future__ import annotations from enum import StrEnum -from typing import Any, Literal, TypeVar +from typing import Annotated, Any, Generic, Literal, TypeVar from geojson_pydantic import Feature, FeatureCollection from geojson_pydantic.geometries import Geometry 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 @@ -78,9 +79,20 @@ 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.""" + 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) diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index 1c05b85..a0be397 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -3,7 +3,7 @@ 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 from geojson_pydantic.base import _GeoJsonBase from geojson_pydantic.geometries import Geometry @@ -16,6 +16,7 @@ field_validator, model_validator, ) +from typing_extensions import TypeVar as DefaultTypeVar from .constants import STAPI_VERSION from .geometry import compute_geometry_bbox @@ -64,9 +65,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) @@ -75,7 +86,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( @@ -86,7 +97,7 @@ def new( ) -T = TypeVar("T", bound=OrderStatus) +T = TypeVar("T", bound=OrderStatus[Any]) class OrderStatusCollection(BaseModel, Generic[T]): diff --git a/stapi-pydantic/tests/test_opportunity.py b/stapi-pydantic/tests/test_opportunity.py index 77e451b..fdc8543 100644 --- a/stapi-pydantic/tests/test_opportunity.py +++ b/stapi-pydantic/tests/test_opportunity.py @@ -22,6 +22,24 @@ } +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"} diff --git a/stapi-pydantic/tests/test_order.py b/stapi-pydantic/tests/test_order.py index 146afca..94beced 100644 --- a/stapi-pydantic/tests/test_order.py +++ b/stapi-pydantic/tests/test_order.py @@ -1,4 +1,5 @@ import datetime +from enum import StrEnum from typing import Any import pydantic @@ -28,6 +29,35 @@ def test_order_status_new() -> 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 diff --git a/uv.lock b/uv.lock index d715259..9334062 100644 --- a/uv.lock +++ b/uv.lock @@ -2652,6 +2652,7 @@ dependencies = [ { name = "cql2" }, { name = "geojson-pydantic" }, { name = "pydantic" }, + { name = "typing-extensions" }, ] [package.dev-dependencies] @@ -2664,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] From 055a5fbaaa273c73cba64aa930de44877ef4c5bd Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Fri, 24 Jul 2026 10:28:36 -0700 Subject: [PATCH 22/25] fix!: align model schemas with spec required-ness and extensibility - bbox non-nullable and serialization-required on Order/Opportunity - spec-REQUIRED defaulted fields (type, stapi_type, stapi_version, links, conformsTo, ...) marked required in serialization schemas - Link schema no longer degrades to a bare object in serialization - stored order requests and search parameters round-trip unknown fields - Opportunity id is string-only; collections omit null id - Product.description required per spec - numberMatched available on all collections - clear error for bbox computation on empty geometries Co-Authored-By: Claude Fable 5 --- .../src/stapi_pydantic/conformance.py | 4 +- stapi-pydantic/src/stapi_pydantic/geometry.py | 2 + .../src/stapi_pydantic/opportunity.py | 30 +++++++++++-- stapi-pydantic/src/stapi_pydantic/order.py | 32 ++++++++++---- stapi-pydantic/src/stapi_pydantic/product.py | 11 +++-- stapi-pydantic/src/stapi_pydantic/root.py | 4 +- .../src/stapi_pydantic/search_parameters.py | 6 ++- stapi-pydantic/src/stapi_pydantic/shared.py | 26 ++++++------ stapi-pydantic/tests/test_opportunity.py | 29 +++++++++++++ stapi-pydantic/tests/test_order.py | 42 +++++++++++++++++++ stapi-pydantic/tests/test_product.py | 18 ++++++++ stapi-pydantic/tests/test_shared.py | 30 +++++++++++++ 12 files changed, 204 insertions(+), 30 deletions(-) create mode 100644 stapi-pydantic/tests/test_shared.py 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/geometry.py b/stapi-pydantic/src/stapi_pydantic/geometry.py index 3006114..c760420 100644 --- a/stapi-pydantic/src/stapi_pydantic/geometry.py +++ b/stapi-pydantic/src/stapi_pydantic/geometry.py @@ -20,6 +20,8 @@ def flatten(coords: Any) -> list[list[float]]: 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): diff --git a/stapi-pydantic/src/stapi_pydantic/opportunity.py b/stapi-pydantic/src/stapi_pydantic/opportunity.py index 493b648..413f296 100644 --- a/stapi-pydantic/src/stapi_pydantic/opportunity.py +++ b/stapi-pydantic/src/stapi_pydantic/opportunity.py @@ -1,10 +1,11 @@ from __future__ import annotations from enum import StrEnum -from typing import Annotated, Any, Generic, Literal, TypeVar +from typing import Annotated, Any, Generic, Literal, TypeVar, cast from geojson_pydantic import Feature, FeatureCollection from geojson_pydantic.geometries import Geometry +from geojson_pydantic.types import BBox from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator from typing_extensions import TypeVar as DefaultTypeVar @@ -12,7 +13,7 @@ from .datetime_interval import DatetimeInterval from .geometry import compute_geometry_bbox from .search_parameters import SearchParameters -from .shared import Link +from .shared import Link, NumberMatched # Copied and modified from https://github.com/stac-utils/stac-pydantic/blob/main/stac_pydantic/item.py#L11 @@ -49,10 +50,17 @@ 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) @@ -64,11 +72,17 @@ def compute_bbox(self) -> Opportunity[G, P]: 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): @@ -91,6 +105,8 @@ class OpportunitySearchStatus(BaseModel, Generic[SearchStatusCode]): (``OpportunitySearchStatus[MyCodes]``) to constrain status_code to an implementation-defined set.""" + model_config = ConfigDict(json_schema_serialization_defaults_required=True) + timestamp: AwareDatetime status_code: SearchStatusCode reason_code: str | None = None @@ -99,6 +115,8 @@ class OpportunitySearchStatus(BaseModel, Generic[SearchStatusCode]): class OpportunitySearchRecord(BaseModel): + model_config = ConfigDict(json_schema_serialization_defaults_required=True) + id: str product_id: str request: OpportunityRequest @@ -109,17 +127,23 @@ class OpportunitySearchRecord(BaseModel): 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 a0be397..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 Annotated, 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, @@ -22,7 +23,7 @@ from .geometry import compute_geometry_bbox from .opportunity import OpportunityProperties from .search_parameters import SearchParameters -from .shared import Link +from .shared import Link, NumberMatched Props = TypeVar("Props", bound=dict[str, Any] | BaseModel) Geom = TypeVar("Geom", bound=Geometry) @@ -101,10 +102,13 @@ def new( class OrderStatusCollection(BaseModel, Generic[T]): + model_config = ConfigDict(serialize_by_alias=True, json_schema_serialization_defaults_required=True) + 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 StoredOrderRequest(BaseModel): @@ -115,21 +119,27 @@ class StoredOrderRequest(BaseModel): 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 order_request: StoredOrderRequest - model_config = ConfigDict(extra="allow") - # 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 @@ -138,6 +148,10 @@ 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) @@ -161,14 +175,18 @@ def compute_bbox(self) -> Order[T]: # 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""" diff --git a/stapi-pydantic/src/stapi_pydantic/product.py b/stapi-pydantic/src/stapi_pydantic/product.py index ed0a3ac..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,7 +51,10 @@ def with_links(self, links: list[Link] | None = None) -> Self: class ProductsCollection(BaseModel): + 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 index 65e377d..b103b81 100644 --- a/stapi-pydantic/src/stapi_pydantic/search_parameters.py +++ b/stapi-pydantic/src/stapi_pydantic/search_parameters.py @@ -1,5 +1,5 @@ from geojson_pydantic.geometries import Geometry -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from .datetime_interval import OpenDatetimeInterval from .filter import CQL2Filter @@ -16,3 +16,7 @@ class SearchParameters(BaseModel): 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_opportunity.py b/stapi-pydantic/tests/test_opportunity.py index fdc8543..912a28b 100644 --- a/stapi-pydantic/tests/test_opportunity.py +++ b/stapi-pydantic/tests/test_opportunity.py @@ -136,6 +136,35 @@ def test_opportunity_bbox_3d_geometry() -> None: 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( diff --git a/stapi-pydantic/tests/test_order.py b/stapi-pydantic/tests/test_order.py index 94beced..58138ed 100644 --- a/stapi-pydantic/tests/test_order.py +++ b/stapi-pydantic/tests/test_order.py @@ -12,6 +12,7 @@ OrderRequest, OrderStatus, OrderStatusCode, + StoredOrderRequest, ) SEARCH_PARAMS = { @@ -153,6 +154,47 @@ def test_order_bbox_3d_geometry() -> None: 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") diff --git a/stapi-pydantic/tests/test_product.py b/stapi-pydantic/tests/test_product.py index 55215e0..46a26df 100644 --- a/stapi-pydantic/tests/test_product.py +++ b/stapi-pydantic/tests/test_product.py @@ -1,3 +1,5 @@ +import pydantic +import pytest from stapi_pydantic import Product, ProductsCollection @@ -7,3 +9,19 @@ def test_products_collection_stapi_fields() -> None: 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_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", []) From ebd8a0226ca00097b76e1ce536fe8f717e7a6382 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Fri, 24 Jul 2026 10:41:27 -0700 Subject: [PATCH 23/25] fix!: spec-conformance fixes for capability advertisement and links - products advertise sync/async opportunity classes per actual capability - search records carry rel=monitor links when statuses endpoint exists - landing page uses spec rel search-records - Preference-Applied always sent when a preference was specified - statuses endpoint and conformance gated on async search support - Location headers and correct media types documented in OpenAPI Co-Authored-By: Claude Fable 5 --- .../stapi_fastapi/routers/product_router.py | 51 +++- .../src/stapi_fastapi/routers/root_router.py | 60 +++- stapi-fastapi/tests/backends.py | 8 +- stapi-fastapi/tests/conftest.py | 4 +- stapi-fastapi/tests/shared.py | 8 +- stapi-fastapi/tests/test_opportunity_async.py | 260 +++++++++++++++++- 6 files changed, 353 insertions(+), 38 deletions(-) diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index f51ccba..8206383 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -22,7 +22,6 @@ Link, OpportunityCollection, OpportunityRequest, - OpportunitySearchRecord, Order, OrderRequest, OrderStatus, @@ -81,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) @@ -163,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, @@ -181,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"], ) @@ -301,7 +325,10 @@ 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) @@ -315,7 +342,7 @@ async def search_opportunities_async( 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) diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index 15814d9..9164079 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -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}"), ), ) @@ -379,7 +379,7 @@ async def get_opportunity_search_records( 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( @@ -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") @@ -471,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: @@ -498,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/backends.py b/stapi-fastapi/tests/backends.py index 16e3429..dcfdc48 100644 --- a/stapi-fastapi/tests/backends.py +++ b/stapi-fastapi/tests/backends.py @@ -128,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)) diff --git a/stapi-fastapi/tests/conftest.py b/stapi-fastapi/tests/conftest.py index 7840e00..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) 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_async.py b/stapi-fastapi/tests/test_opportunity_async.py index 2fbc9a3..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, From 2c220415718f385677765fa1a3f24ed2c0c2a4e7 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Fri, 24 Jul 2026 10:46:12 -0700 Subject: [PATCH 24/25] fix!: correct conformance checking scope and URI matching - opportunity capability checks consult the product's conformsTo per spec, not the root document - conformance URI patterns anchor the version as a single path segment - enum gains API-level extension classes (order-statuses, searches-opportunity, searches-opportunity-statuses) - fixtures model a spec-compliant server Co-Authored-By: Claude Fable 5 --- pystapi-client/src/pystapi_client/client.py | 52 +++++++++++-- .../src/pystapi_client/conformance.py | 7 +- pystapi-client/tests/conftest.py | 3 + .../tests/fixtures/landing_page.json | 7 +- pystapi-client/tests/fixtures/products.json | 10 +++ pystapi-client/tests/test_client.py | 76 +++++++++++++++++++ 6 files changed, 144 insertions(+), 11 deletions(-) diff --git a/pystapi-client/src/pystapi_client/client.py b/pystapi-client/src/pystapi_client/client.py index 7e20fe3..c41743a 100644 --- a/pystapi-client/src/pystapi_client/client.py +++ b/pystapi-client/src/pystapi_client/client.py @@ -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 diff --git a/pystapi-client/src/pystapi_client/conformance.py b/pystapi-client/src/pystapi_client/conformance.py index 2edb546..24d3ff0 100644 --- a/pystapi-client/src/pystapi_client/conformance.py +++ b/pystapi-client/src/pystapi_client/conformance.py @@ -6,7 +6,12 @@ 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 = "/opportunities-async" @@ -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 c09806b..edc1c80 100644 --- a/pystapi-client/tests/fixtures/landing_page.json +++ b/pystapi-client/tests/fixtures/landing_page.json @@ -4,10 +4,9 @@ "description": "This API demonstrated the landing page for a SpatioTemporal Asset Tasking API", "conformsTo": [ "https://stapi.example.com/v0.2.0/core", - "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" + "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 e616e90..e9fb083 100644 --- a/pystapi-client/tests/fixtures/products.json +++ b/pystapi-client/tests/fixtures/products.json @@ -7,6 +7,12 @@ "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": [ @@ -110,6 +116,10 @@ "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 4b9b1b5..c0370da 100644 --- a/pystapi-client/tests/test_client.py +++ b/pystapi-client/tests/test_client.py @@ -35,3 +35,79 @@ 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") From 66ee7857df59a058b30afc6cc1026f28a9a0bd5e Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Fri, 24 Jul 2026 10:59:39 -0700 Subject: [PATCH 25/25] fix: clean and harden the exported OpenAPI document - generic deterministic operationIds; no concrete-product leakage - readable component schema names; orphan BaseModel eliminated - conformance URI example on GET /conformance - exported doc verified to carry required-field, Location-header, media-type, and extensible-status fixes from the model/router work - full schema-inventory and subprocess determinism invariant tests - bound stapi-fastapi>=0.9.0; license/authors metadata Co-Authored-By: Claude Fable 5 --- pystapi-schema-generator/pyproject.toml | 7 +- .../pystapi_schema_generator/application.py | 286 +++++++++++++++++- .../tests/test_application.py | 227 +++++++++++++- 3 files changed, 502 insertions(+), 18 deletions(-) diff --git a/pystapi-schema-generator/pyproject.toml b/pystapi-schema-generator/pyproject.toml index 957a43a..04a4c56 100644 --- a/pystapi-schema-generator/pyproject.toml +++ b/pystapi-schema-generator/pyproject.toml @@ -3,9 +3,14 @@ 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", + "stapi-fastapi>=0.9.0", "pyyaml>=6.0", ] diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/application.py b/pystapi-schema-generator/src/pystapi_schema_generator/application.py index 44d274f..00a7d55 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/application.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/application.py @@ -15,12 +15,15 @@ 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 @@ -33,6 +36,11 @@ 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", @@ -44,6 +52,16 @@ _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.""" @@ -60,7 +78,7 @@ def create_reference_app() -> FastAPI: ) example_product = Product( - id="example", + id=_PRODUCT_ID, title="Example Product", description=( "This is an example product that demonstrates the STAPI specification. " @@ -88,12 +106,7 @@ def create_reference_app() -> FastAPI: get_opportunity_search_records=_not_implemented, get_opportunity_search_record=_not_implemented, get_opportunity_search_record_statuses=_not_implemented, - conformances=[ - API.core, - API.order_statuses, - API.searches_opportunity, - API.searches_opportunity_statuses, - ], + conformances=list(_ADVERTISED_CONFORMANCE), ) root_router.add_product(example_product) @@ -170,21 +183,257 @@ def _openapi_with_external_docs() -> dict[str, Any]: 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 ``example`` product paths into templated form. + """Rewrite the concrete product paths into templated form. - ``/products/example`` -> ``/products/{productId}`` and - ``/products/example/...`` -> ``/products/{productId}/...``, injecting a + ``/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 == "/products/example": + if path == concrete: new_path = "/products/{productId}" - elif path.startswith("/products/example/"): - new_path = "/products/{productId}/" + path[len("/products/example/") :] + elif path.startswith(concrete + "/"): + new_path = "/products/{productId}/" + path[len(concrete + "/") :] else: new_paths[path] = path_item continue @@ -204,7 +453,16 @@ def _templatize_product_paths(openapi: dict[str, Any]) -> dict[str, Any]: def export_openapi() -> dict[str, Any]: """Build the reference app and return its post-processed OpenAPI schema.""" app = create_reference_app() - return _templatize_product_paths(app.openapi()) + 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: diff --git a/pystapi-schema-generator/tests/test_application.py b/pystapi-schema-generator/tests/test_application.py index 1a9768e..0dcc63c 100644 --- a/pystapi-schema-generator/tests/test_application.py +++ b/pystapi-schema-generator/tests/test_application.py @@ -1,6 +1,10 @@ +import os +import subprocess +import sys from typing import Any -from pystapi_schema_generator.application import export_openapi +from pystapi_schema_generator.application import _ADVERTISED_CONFORMANCE, export_openapi +from stapi_fastapi.conformance import API from stapi_pydantic import STAPI_VERSION EXPECTED_PATHS = { @@ -22,13 +26,104 @@ "/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_no_concrete_example_product_paths() -> None: - assert "/products/example" not in str(export_openapi()) +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: @@ -48,6 +143,116 @@ def test_info_and_external_docs() -> None: 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 ( @@ -63,3 +268,19 @@ 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")