Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 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 body["conformsTo"] == [API.core, API.order_statuses]

assert_link("GET /", body, "self", "/")
assert_link("GET /", body, "service-description", "/openapi.json")
Expand Down
Loading