diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index bf60df8..d6031a3 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -22,6 +22,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a - stapi-fastapi is now using stapi-pydantic models, deduplicating code - Product in stapi-fastapi is now subclass of Product from stapi-pydantic - How conformances work ([#90](https://github.com/stapi-spec/pystapi/pull/90)) +- Async behaviors align with spec changes ([#90](https://github.com/stapi-spec/pystapi/pull/90)) ## [0.6.0] - 2025-02-11 diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index b11bcad..2531bfc 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -33,6 +33,7 @@ Product as ProductPydantic, ) +from stapi_fastapi.conformance import PRODUCT as PRODUCT_CONFORMACES from stapi_fastapi.constants import TYPE_JSON from stapi_fastapi.errors import NotFoundError, QueryablesError from stapi_fastapi.models.product import Product @@ -66,6 +67,23 @@ def get_prefer(prefer: str | None = Header(None)) -> str | None: return Prefer(prefer) +def build_conformances(product: Product, root_router: RootRouter) -> list[str]: + # FIXME we can make this check more robust + if not any(conformance.startswith("https://geojson.org/schema/") for conformance in product.conformsTo): + raise ValueError("product conformance does not contain at least one geojson conformance") + + conformances = set(product.conformsTo) + + if product.supports_opportunity_search: + 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) + + class ProductRouter(APIRouter): # FIXME ruff is complaining that the init is too complex def __init__( # noqa @@ -77,17 +95,9 @@ def __init__( # noqa ) -> None: super().__init__(*args, **kwargs) - if root_router.supports_async_opportunity_search and not product.supports_async_opportunity_search: - raise ValueError( - f"Product '{product.id}' must support async opportunity search since the root router does", - ) - - # FIXME we can make this check more robust - if not any(conformance.startswith("https://geojson.org/schema/") for conformance in product.conformsTo): - raise ValueError("product conformance does not contain at least one geojson conformance") - self.product = product self.root_router = root_router + self.conformances = build_conformances(product, root_router) self.add_api_route( path="", @@ -154,7 +164,9 @@ async def _create_order( tags=["Products"], ) - if product.supports_opportunity_search or root_router.supports_async_opportunity_search: + if product.supports_opportunity_search or ( + self.product.supports_async_opportunity_search and self.root_router.supports_async_opportunity_search + ): self.add_api_route( path="/opportunities", endpoint=self.search_opportunities, @@ -176,7 +188,7 @@ async def _create_order( tags=["Products"], ) - if root_router.supports_async_opportunity_search: + if product.supports_async_opportunity_search and root_router.supports_async_opportunity_search: self.add_api_route( path="/opportunities/{opportunity_collection_id}", endpoint=self.get_opportunity_collection, @@ -237,7 +249,9 @@ def get_product(self, request: Request) -> ProductPydantic: ), ] - if self.product.supports_opportunity_search or self.root_router.supports_async_opportunity_search: + if self.product.supports_opportunity_search or ( + self.product.supports_async_opportunity_search and self.root_router.supports_async_opportunity_search + ): links.append( Link( href=str( @@ -263,9 +277,9 @@ async def search_opportunities( Explore the opportunities available for a particular set of queryables """ # sync - if not self.root_router.supports_async_opportunity_search or ( - prefer is Prefer.wait and self.product.supports_opportunity_search - ): + if not ( + self.root_router.supports_async_opportunity_search and self.product.supports_async_opportunity_search + ) or (prefer is Prefer.wait and self.product.supports_opportunity_search): return await self.search_opportunities_sync( search, request, @@ -362,7 +376,7 @@ def get_product_conformance(self) -> Conformance: """ Return conformance urls of a specific product """ - return Conformance.model_validate({"conforms_to": self.product.conformsTo}) + return Conformance.model_validate({"conforms_to": self.conformances}) def get_product_queryables(self) -> JsonSchemaModel: """ diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index 41486b5..c238c5a 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -54,7 +54,7 @@ def __init__( self, get_orders: GetOrders, get_order: GetOrder, - get_order_statuses: GetOrderStatuses, # type: ignore + get_order_statuses: GetOrderStatuses | None = None, # type: ignore get_opportunity_search_records: GetOpportunitySearchRecords | None = None, get_opportunity_search_record: GetOpportunitySearchRecord | None = None, get_opportunity_search_record_statuses: GetOpportunitySearchRecordStatuses | None = None, @@ -67,18 +67,14 @@ def __init__( ) -> None: super().__init__(*args, **kwargs) - api_conformances = API_CONFORMANCE.all() - for conformance in conformances: - if conformance not in api_conformances: - raise ValueError(f"{conformance} is not a valid API conformance") + _conformances = set(conformances) self._get_orders = get_orders self._get_order = get_order - self._get_order_statuses = get_order_statuses + self.__get_order_statuses = get_order_statuses self.__get_opportunity_search_records = get_opportunity_search_records self.__get_opportunity_search_record = get_opportunity_search_record self.__get_opportunity_search_record_statuses = get_opportunity_search_record_statuses - self.conformances = conformances self.name = name self.openapi_endpoint_name = openapi_endpoint_name self.docs_endpoint_name = docs_endpoint_name @@ -132,15 +128,18 @@ def __init__( tags=["Orders"], ) - self.add_api_route( - "/orders/{order_id}/statuses", - self.get_order_statuses, - methods=["GET"], - name=f"{self.name}:{LIST_ORDER_STATUSES}", - tags=["Orders"], - ) + if self.get_order_statuses is not None: + _conformances.add(API_CONFORMANCE.order_statuses) + self.add_api_route( + "/orders/{order_id}/statuses", + self.get_order_statuses, + methods=["GET"], + name=f"{self.name}:{LIST_ORDER_STATUSES}", + tags=["Orders"], + ) - if API_CONFORMANCE.searches_opportunity in conformances: + if self.supports_async_opportunity_search: + _conformances.add(API_CONFORMANCE.searches_opportunity) self.add_api_route( "/searches/opportunities", self.get_opportunity_search_records, @@ -159,7 +158,8 @@ def __init__( tags=["Opportunities"], ) - if API_CONFORMANCE.searches_opportunity_statuses in conformances: + 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, @@ -169,6 +169,8 @@ def __init__( tags=["Opportunities"], ) + self.conformances = list(_conformances) + def get_root(self, request: Request) -> RootResponse: links = [ Link( @@ -466,6 +468,12 @@ def opportunity_search_record_self_link( type=TYPE_JSON, ) + @property + def _get_order_statuses(self) -> GetOrderStatuses: # type: ignore + if not self.__get_order_statuses: + raise AttributeError("Root router does not support order status history") + return self.__get_order_statuses + @property def _get_opportunity_search_records(self) -> GetOpportunitySearchRecords: if not self.__get_opportunity_search_records: @@ -481,13 +489,9 @@ def _get_opportunity_search_record(self) -> GetOpportunitySearchRecord: @property def _get_opportunity_search_record_statuses(self) -> GetOpportunitySearchRecordStatuses: if not self.__get_opportunity_search_record_statuses: - raise AttributeError("Root router does not support async opportunity search") + raise AttributeError("Root router does not support async opportunity search status history") return self.__get_opportunity_search_record_statuses @property def supports_async_opportunity_search(self) -> bool: - return ( - API_CONFORMANCE.searches_opportunity in self.conformances - and self._get_opportunity_search_records is not None - and self._get_opportunity_search_record is not None - ) + return self.__get_opportunity_search_records is not None and self.__get_opportunity_search_record is not None diff --git a/stapi-fastapi/tests/test_conformance.py b/stapi-fastapi/tests/test_conformance.py index 71b2092..36e54af 100644 --- a/stapi-fastapi/tests/test_conformance.py +++ b/stapi-fastapi/tests/test_conformance.py @@ -11,7 +11,7 @@ def test_conformance(stapi_client: TestClient) -> None: body = res.json() - assert body["conformsTo"] == [API.core] + assert set(body["conformsTo"]) == {API.core, API.order_statuses} def test_all() -> None: diff --git a/stapi-fastapi/tests/test_root.py b/stapi-fastapi/tests/test_root.py index 6756d3f..00b4ace 100644 --- a/stapi-fastapi/tests/test_root.py +++ b/stapi-fastapi/tests/test_root.py @@ -11,7 +11,7 @@ def test_root(stapi_client: TestClient, assert_link) -> None: body = res.json() - assert body["conformsTo"] == [API.core] + assert set(body["conformsTo"]) == {API.core, API.order_statuses} assert_link("GET /", body, "self", "/") assert_link("GET /", body, "service-description", "/openapi.json")