Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions stapi-fastapi/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
46 changes: 30 additions & 16 deletions stapi-fastapi/src/stapi_fastapi/routers/product_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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="",
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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:
"""
Expand Down
48 changes: 26 additions & 22 deletions stapi-fastapi/src/stapi_fastapi/routers/root_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -169,6 +169,8 @@ def __init__(
tags=["Opportunities"],
)

self.conformances = list(_conformances)

def get_root(self, request: Request) -> RootResponse:
links = [
Link(
Expand Down Expand Up @@ -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:
Expand All @@ -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
2 changes: 1 addition & 1 deletion stapi-fastapi/tests/test_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion stapi-fastapi/tests/test_root.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down