From 9a350256e8bb4833562529ad52954c5284ac5a6e Mon Sep 17 00:00:00 2001 From: Phil Varner Date: Tue, 23 Dec 2025 11:21:00 -0500 Subject: [PATCH 01/11] add typing to support variable OrderStatus objects --- .../stapi_fastapi/backends/root_backend.py | 7 +++---- .../stapi_fastapi/routers/product_router.py | 9 ++++++--- .../src/stapi_fastapi/routers/root_router.py | 20 ++++++++++--------- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py index f13e5cc..95ee48f 100644 --- a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py +++ b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py @@ -11,9 +11,11 @@ OrderStatus, ) +T = TypeVar("T", bound=OrderStatus) + GetOrders = Callable[ [str | None, int, Request], - Coroutine[Any, Any, ResultE[tuple[list[Order[OrderStatus]], Maybe[str], Maybe[int]]]], + Coroutine[Any, Any, ResultE[tuple[list[Order[T]], Maybe[str], Maybe[int]]]], ] """ Type alias for an async function that returns a list of existing Orders. @@ -48,9 +50,6 @@ """ -T = TypeVar("T", bound=OrderStatus) - - GetOrderStatuses = Callable[ [str, str | None, int, Request], Coroutine[Any, Any, ResultE[Maybe[tuple[list[T], Maybe[str]]]]], diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index 430ae00..8c47c3d 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -2,7 +2,7 @@ import logging import traceback -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypeVar from fastapi import ( Depends, @@ -68,7 +68,10 @@ def get_prefer(prefer: str | None = Header(None)) -> str | None: return Prefer(prefer) -def build_conformances(product: Product, root_router: RootRouter) -> list[str]: +T = TypeVar("T", bound=OrderStatus) + + +def build_conformances(product: Product, root_router: RootRouter[T]) -> 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") @@ -90,7 +93,7 @@ class ProductRouter(StapiFastapiBaseRouter): def __init__( # noqa self, product: Product, - root_router: RootRouter, + root_router: RootRouter[T], *args: Any, **kwargs: Any, ) -> None: diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index c33abc1..10bf3c2 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -1,6 +1,6 @@ import logging import traceback -from typing import Any +from typing import Any, Generic, TypeVar from fastapi import HTTPException, Request, status from fastapi.datastructures import URL @@ -50,11 +50,13 @@ logger = logging.getLogger(__name__) +T = TypeVar("T", bound=OrderStatus) -class RootRouter(StapiFastapiBaseRouter): + +class RootRouter(StapiFastapiBaseRouter, Generic[T]): def __init__( self, - get_orders: GetOrders, + get_orders: GetOrders[T], get_order: GetOrder, get_order_statuses: GetOrderStatuses | None = None, # type: ignore get_opportunity_search_records: GetOpportunitySearchRecords | None = None, @@ -240,7 +242,7 @@ def get_products(self, request: Request, next: str | None = None, limit: int = 1 async def get_orders( # noqa: C901 self, request: Request, next: str | None = None, limit: int = 10 - ) -> OrderCollection[OrderStatus]: + ) -> OrderCollection[T]: links: list[Link] = [] orders_count: int | None = None match await self._get_orders(next, limit, request): @@ -271,13 +273,13 @@ async def get_orders( # noqa: C901 case _: raise AssertionError("Expected code to be unreachable") - return OrderCollection( + return OrderCollection[T]( features=orders, links=links, number_matched=orders_count, ) - async def get_order(self, order_id: str, request: Request) -> Order[OrderStatus]: + async def get_order(self, order_id: str, request: Request) -> Order[T]: """ Get details for order with `order_id`. """ @@ -306,7 +308,7 @@ async def get_order_statuses( request: Request, next: str | None = None, limit: int = 10, - ) -> OrderStatuses: # type: ignore + ) -> OrderStatuses[T]: links: list[Link] = [] match await self._get_order_statuses(order_id, next, limit, request): case Success(Some((statuses, maybe_pagination_token))): @@ -350,7 +352,7 @@ def generate_order_href(self, request: Request, order_id: str) -> URL: def generate_order_statuses_href(self, request: Request, order_id: str) -> URL: return self.url_for(request, f"{self.name}:{LIST_ORDER_STATUSES}", order_id=order_id) - def order_links(self, order: Order[OrderStatus], request: Request) -> list[Link]: + def order_links(self, order: Order[T], request: Request) -> list[Link]: return [ Link( href=self.generate_order_href(request, order.id), @@ -464,7 +466,7 @@ def opportunity_search_record_self_link( return json_link("self", self.generate_opportunity_search_record_href(request, opportunity_search_record.id)) @property - def _get_order_statuses(self) -> GetOrderStatuses: # type: ignore + def _get_order_statuses(self) -> GetOrderStatuses[T]: if not self.__get_order_statuses: raise AttributeError("Root router does not support order status history") return self.__get_order_statuses From 49c1e3d40c2961f3fdd3018dae70cbeaa9eb51eb Mon Sep 17 00:00:00 2001 From: Phil Varner Date: Mon, 29 Dec 2025 16:51:50 -0500 Subject: [PATCH 02/11] better types --- .../stapi_fastapi/backends/root_backend.py | 109 ++++++++++-------- .../stapi_fastapi/routers/product_router.py | 75 ++++++------ .../src/stapi_fastapi/routers/root_router.py | 57 ++++++--- stapi-pydantic/src/stapi_pydantic/order.py | 22 ++-- 4 files changed, 153 insertions(+), 110 deletions(-) diff --git a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py index 95ee48f..c71574e 100644 --- a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py +++ b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py @@ -1,5 +1,5 @@ from collections.abc import Callable, Coroutine -from typing import Any, TypeVar +from typing import Any, Generic, Protocol, TypeVar from fastapi import Request from returns.maybe import Maybe @@ -11,68 +11,75 @@ OrderStatus, ) -T = TypeVar("T", bound=OrderStatus) +OrderStatusBound = TypeVar("OrderStatusBound", bound=OrderStatus) -GetOrders = Callable[ - [str | None, int, Request], - Coroutine[Any, Any, ResultE[tuple[list[Order[T]], Maybe[str], Maybe[int]]]], -] -""" -Type alias for an async function that returns a list of existing Orders. -Args: - next (str | None): A pagination token. - limit (int): The maximum number of orders to return in a page. - request (Request): FastAPI's Request object. +class GetOrders(Protocol, Generic[OrderStatusBound]): + """Type alias for an async function that returns a list of existing Orders. -Returns: - A tuple containing a list of orders and a pagination token. + Args: + next (str | None): A pagination token. + limit (int): The maximum number of orders to return in a page. + request (Request): FastAPI's Request object. - - Should return returns.result.Success[tuple[list[Order], returns.maybe.Some[str]]] - if including a pagination token - - Should return returns.result.Success[tuple[list[Order], returns.maybe.Nothing]] - if not including a pagination token - - Returning returns.result.Failure[Exception] will result in a 500. -""" + Returns: + A tuple containing a list of orders and a pagination token. -GetOrder = Callable[[str, Request], Coroutine[Any, Any, ResultE[Maybe[Order[OrderStatus]]]]] -""" -Type alias for an async function that gets details for the order with `order_id`. + - Should return returns.result.Success[tuple[list[Order], returns.maybe.Some[str]]] + if including a pagination token + - Should return returns.result.Success[tuple[list[Order], returns.maybe.Nothing]] + if not including a pagination token + - Returning returns.result.Failure[Exception] will result in a 500. + """ -Args: - order_id (str): The order ID. - request (Request): FastAPI's Request object. + async def __call__( + self, + next: str | None, + limit: int, + request: Request, + ) -> ResultE[tuple[list[Order[OrderStatusBound]], Maybe[str], Maybe[int]]]: ... -Returns: - - Should return returns.result.Success[returns.maybe.Some[Order]] if order is found. - - Should return returns.result.Success[returns.maybe.Nothing] if the order is not found or if access is denied. - - Returning returns.result.Failure[Exception] will result in a 500. -""" +class GetOrder(Protocol, Generic[OrderStatusBound]): + """Type alias for an async function that gets details for the order with `order_id`. -GetOrderStatuses = Callable[ - [str, str | None, int, Request], - Coroutine[Any, Any, ResultE[Maybe[tuple[list[T], Maybe[str]]]]], -] -""" -Type alias for an async function that gets statuses for the order with `order_id`. + Args: + order_id (str): The order ID. + request (Request): FastAPI's Request object. -Args: - order_id (str): The order ID. - next (str | None): A pagination token. - limit (int): The maximum number of statuses to return in a page. - request (Request): FastAPI's Request object. + Returns: + - Should return returns.result.Success[returns.maybe.Some[Order]] if order is found. + - Should return returns.result.Success[returns.maybe.Nothing] if the order is not found or if access is denied. + - Returning returns.result.Failure[Exception] will result in a 500. + """ -Returns: - A tuple containing a list of order statuses and a pagination token. + async def __call__(self, order_id: str, request: Request) -> ResultE[Maybe[Order[OrderStatusBound]]]: ... + + +class GetOrderStatuses(Protocol, Generic[OrderStatusBound]): + """Type alias for an async function that gets statuses for the order with `order_id`. + + Args: + order_id (str): The order ID. + next (str | None): A pagination token. + limit (int): The maximum number of statuses to return in a page. + request (Request): FastAPI's Request object. + + Returns: + A tuple containing a list of order statuses and a pagination token. + + - Should return returns.result.Success[returns.maybe.Some[tuple[list[OrderStatus], returns.maybe.Some[str]]] + if order is found and including a pagination token. + - Should return returns.result.Success[returns.maybe.Some[tuple[list[OrderStatus], returns.maybe.Nothing]]] + if order is found and not including a pagination token. + - Should return returns.result.Success[returns.maybe.Nothing] if the order is not found or if access is denied. + - Returning returns.result.Failure[Exception] will result in a 500. + """ + + async def __call__( + self, order_id: str, _next: str | None, limit: int, request: Request + ) -> ResultE[Maybe[tuple[list[OrderStatusBound], Maybe[str]]]]: ... - - Should return returns.result.Success[returns.maybe.Some[tuple[list[OrderStatus], returns.maybe.Some[str]]] - if order is found and including a pagination token. - - Should return returns.result.Success[returns.maybe.Some[tuple[list[OrderStatus], returns.maybe.Nothing]]] - if order is found and not including a pagination token. - - Should return returns.result.Success[returns.maybe.Nothing] if the order is not found or if access is denied. - - Returning returns.result.Failure[Exception] will result in a 500. -""" GetOpportunitySearchRecords = Callable[ [str | None, int, Request], diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index 8c47c3d..a8c14d6 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -2,7 +2,7 @@ import logging import traceback -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any from fastapi import ( Depends, @@ -50,7 +50,8 @@ from stapi_fastapi.routers.utils import json_link if TYPE_CHECKING: - from stapi_fastapi.routers import RootRouter + from stapi_fastapi.routers.root_router import ConformancesSupport, RootProvider + logger = logging.getLogger(__name__) @@ -68,10 +69,7 @@ def get_prefer(prefer: str | None = Header(None)) -> str | None: return Prefer(prefer) -T = TypeVar("T", bound=OrderStatus) - - -def build_conformances(product: Product, root_router: RootRouter[T]) -> list[str]: +def build_conformances(product: Product, conformances_support: ConformancesSupport) -> 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") @@ -81,7 +79,7 @@ def build_conformances(product: Product, root_router: RootRouter[T]) -> list[str if product.supports_opportunity_search: conformances.add(PRODUCT_CONFORMACES.opportunities) - if product.supports_async_opportunity_search and root_router.supports_async_opportunity_search: + if product.supports_async_opportunity_search and conformances_support.supports_async_opportunity_search: conformances.add(PRODUCT_CONFORMACES.opportunities) conformances.add(PRODUCT_CONFORMACES.opportunities_async) @@ -93,20 +91,21 @@ class ProductRouter(StapiFastapiBaseRouter): def __init__( # noqa self, product: Product, - root_router: RootRouter[T], + root_provider: RootProvider, *args: Any, **kwargs: Any, ) -> None: super().__init__(*args, **kwargs) self.product = product - self.root_router = root_router - self.conformances = build_conformances(product, root_router) + self.root_provider = root_provider + self.conformances_support: ConformancesSupport = root_provider + self.conformances = build_conformances(product, root_provider) self.add_api_route( path="", endpoint=self.get_product, - name=f"{self.root_router.name}:{self.product.id}:{GET_PRODUCT}", + name=f"{self.root_provider.name}:{self.product.id}:{GET_PRODUCT}", methods=["GET"], summary="Retrieve this product", tags=["Products"], @@ -115,7 +114,7 @@ def __init__( # noqa self.add_api_route( path="/conformance", endpoint=self.get_product_conformance, - name=f"{self.root_router.name}:{self.product.id}:{CONFORMANCE}", + name=f"{self.root_provider.name}:{self.product.id}:{CONFORMANCE}", methods=["GET"], summary="Get conformance urls for the product", tags=["Products"], @@ -124,7 +123,7 @@ def __init__( # noqa self.add_api_route( path="/queryables", endpoint=self.get_product_queryables, - name=f"{self.root_router.name}:{self.product.id}:{GET_QUERYABLES}", + name=f"{self.root_provider.name}:{self.product.id}:{GET_QUERYABLES}", methods=["GET"], summary="Get queryables for the product", tags=["Products"], @@ -133,7 +132,7 @@ def __init__( # noqa self.add_api_route( path="/order-parameters", endpoint=self.get_product_order_parameters, - name=f"{self.root_router.name}:{self.product.id}:{GET_ORDER_PARAMETERS}", + name=f"{self.root_provider.name}:{self.product.id}:{GET_ORDER_PARAMETERS}", methods=["GET"], summary="Get order parameters for the product", tags=["Products"], @@ -160,7 +159,7 @@ async def _create_order( self.add_api_route( path="/orders", endpoint=_create_order, - name=f"{self.root_router.name}:{self.product.id}:{CREATE_ORDER}", + name=f"{self.root_provider.name}:{self.product.id}:{CREATE_ORDER}", methods=["POST"], response_class=GeoJSONResponse, status_code=status.HTTP_201_CREATED, @@ -169,12 +168,13 @@ async def _create_order( ) if product.supports_opportunity_search or ( - self.product.supports_async_opportunity_search and self.root_router.supports_async_opportunity_search + self.product.supports_async_opportunity_search + and self.conformances_support.supports_async_opportunity_search ): self.add_api_route( path="/opportunities", endpoint=self.search_opportunities, - name=f"{self.root_router.name}:{self.product.id}:{SEARCH_OPPORTUNITIES}", + name=f"{self.root_provider.name}:{self.product.id}:{SEARCH_OPPORTUNITIES}", methods=["POST"], response_class=GeoJSONResponse, # unknown why mypy can't see the queryables property on Product, ignoring @@ -192,11 +192,11 @@ async def _create_order( tags=["Products"], ) - if product.supports_async_opportunity_search and root_router.supports_async_opportunity_search: + if product.supports_async_opportunity_search and self.conformances_support.supports_async_opportunity_search: self.add_api_route( path="/opportunities/{opportunity_collection_id}", endpoint=self.get_opportunity_collection, - name=f"{self.root_router.name}:{self.product.id}:{GET_OPPORTUNITY_COLLECTION}", + name=f"{self.root_provider.name}:{self.product.id}:{GET_OPPORTUNITY_COLLECTION}", methods=["GET"], response_class=GeoJSONResponse, summary="Get an Opportunity Collection by ID", @@ -205,17 +205,20 @@ async def _create_order( def get_product(self, request: Request) -> ProductPydantic: links = [ - json_link("self", self.url_for(request, f"{self.root_router.name}:{self.product.id}:{GET_PRODUCT}")), - json_link("conformance", self.url_for(request, f"{self.root_router.name}:{self.product.id}:{CONFORMANCE}")), + json_link("self", self.url_for(request, f"{self.root_provider.name}:{self.product.id}:{GET_PRODUCT}")), json_link( - "queryables", self.url_for(request, f"{self.root_router.name}:{self.product.id}:{GET_QUERYABLES}") + "conformance", self.url_for(request, f"{self.root_provider.name}:{self.product.id}:{CONFORMANCE}") + ), + json_link( + "queryables", + self.url_for(request, f"{self.root_provider.name}:{self.product.id}:{GET_QUERYABLES}"), ), json_link( "order-parameters", - self.url_for(request, f"{self.root_router.name}:{self.product.id}:{GET_ORDER_PARAMETERS}"), + self.url_for(request, f"{self.root_provider.name}:{self.product.id}:{GET_ORDER_PARAMETERS}"), ), Link( - href=self.url_for(request, f"{self.root_router.name}:{self.product.id}:{CREATE_ORDER}"), + href=self.url_for(request, f"{self.root_provider.name}:{self.product.id}:{CREATE_ORDER}"), rel="create-order", type=TYPE_JSON, method="POST", @@ -223,12 +226,13 @@ def get_product(self, request: Request) -> ProductPydantic: ] if self.product.supports_opportunity_search or ( - self.product.supports_async_opportunity_search and self.root_router.supports_async_opportunity_search + self.product.supports_async_opportunity_search + and self.conformances_support.supports_async_opportunity_search ): links.append( json_link( "opportunities", - self.url_for(request, f"{self.root_router.name}:{self.product.id}:{SEARCH_OPPORTUNITIES}"), + self.url_for(request, f"{self.root_provider.name}:{self.product.id}:{SEARCH_OPPORTUNITIES}"), ), ) @@ -246,7 +250,8 @@ async def search_opportunities( """ # sync if not ( - self.root_router.supports_async_opportunity_search and self.product.supports_async_opportunity_search + self.product.supports_async_opportunity_search + and self.conformances_support.supports_async_opportunity_search ) or (prefer is Prefer.wait and self.product.supports_opportunity_search): return await self.search_opportunities_sync( search, @@ -301,7 +306,7 @@ 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: + if prefer is Prefer.wait and self.conformances_support.supports_async_opportunity_search: response.headers["Preference-Applied"] = "wait" return OpportunityCollection(features=features, links=links) @@ -314,10 +319,12 @@ async def search_opportunities_async( ) -> JSONResponse: 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.append( + self.root_provider.opportunity_search_record_self_link(search_record, request) + ) headers = {} headers["Location"] = str( - self.root_router.generate_opportunity_search_record_href(request, search_record.id) + self.root_provider.generate_opportunity_search_record_href(request, search_record.id) ) if prefer is not None: headers["Preference-Applied"] = "respond-async" @@ -368,8 +375,8 @@ async def create_order(self, payload: OrderPayload, request: Request, response: request, ): case Success(order): - order.links.extend(self.root_router.order_links(order, request)) - location = str(self.root_router.generate_order_href(request, order.id)) + order.links.extend(self.root_provider.order_links(order, request)) + location = str(self.root_provider.generate_order_href(request, order.id)) response.headers["Location"] = location return order # type: ignore case Failure(e) if isinstance(e, QueryablesError): @@ -388,7 +395,7 @@ async def create_order(self, payload: OrderPayload, request: Request, response: def order_link(self, request: Request, opp_req: OpportunityPayload) -> Link: return Link( - href=self.url_for(request, f"{self.root_router.name}:{self.product.id}:{CREATE_ORDER}"), + href=self.url_for(request, f"{self.root_provider.name}:{self.product.id}:{CREATE_ORDER}"), rel="create-order", type=TYPE_JSON, method="POST", @@ -423,7 +430,7 @@ async def get_opportunity_collection( "self", self.url_for( request, - f"{self.root_router.name}:{self.product.id}:{GET_OPPORTUNITY_COLLECTION}", + f"{self.root_provider.name}:{self.product.id}:{GET_OPPORTUNITY_COLLECTION}", opportunity_collection_id=opportunity_collection_id, ), ), diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index 10bf3c2..edf1023 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -1,6 +1,7 @@ import logging import traceback -from typing import Any, Generic, TypeVar +from abc import abstractmethod +from typing import Any, Generic, Protocol from fastapi import HTTPException, Request, status from fastapi.datastructures import URL @@ -14,11 +15,11 @@ OpportunitySearchStatus, Order, OrderCollection, - OrderStatus, OrderStatuses, ProductsCollection, RootResponse, ) +from stapi_pydantic.order import OrderStatus, OrderStatusBound from stapi_fastapi.backends.root_backend import ( GetOpportunitySearchRecord, @@ -50,15 +51,39 @@ logger = logging.getLogger(__name__) -T = TypeVar("T", bound=OrderStatus) +class ConformancesSupport(Protocol): + @property + @abstractmethod + def supports_async_opportunity_search(self) -> bool: ... + + +class RootProvider(ConformancesSupport): + @property + @abstractmethod + def name(self) -> str: ... + + @abstractmethod + def opportunity_search_record_self_link( + self, opportunity_search_record: OpportunitySearchRecord, request: Request + ) -> Link: ... -class RootRouter(StapiFastapiBaseRouter, Generic[T]): + @abstractmethod + def generate_opportunity_search_record_href(self, request: Request, search_record_id: str) -> URL: ... + + @abstractmethod + def order_links(self, order: Order[OrderStatusBound], request: Request) -> list[Link]: ... + + @abstractmethod + def generate_order_href(self, request: Request, order_id: str) -> URL: ... + + +class RootRouter(StapiFastapiBaseRouter, RootProvider, Generic[OrderStatusBound]): def __init__( self, - get_orders: GetOrders[T], - get_order: GetOrder, - get_order_statuses: GetOrderStatuses | None = None, # type: ignore + get_orders: GetOrders[OrderStatusBound], + get_order: GetOrder[OrderStatusBound], + get_order_statuses: GetOrderStatuses[OrderStatusBound] | None = None, get_opportunity_search_records: GetOpportunitySearchRecords | None = None, get_opportunity_search_record: GetOpportunitySearchRecord | None = None, get_opportunity_search_record_statuses: GetOpportunitySearchRecordStatuses | None = None, @@ -79,7 +104,7 @@ def __init__( 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.name = name + self._name = name self.openapi_endpoint_name = openapi_endpoint_name self.docs_endpoint_name = docs_endpoint_name self.product_ids: list[str] = [] @@ -175,6 +200,10 @@ def __init__( self.conformances = list(_conformances) + @property + def name(self) -> str: + return self._name + def get_root(self, request: Request) -> RootResponse: links = [ json_link( @@ -242,7 +271,7 @@ def get_products(self, request: Request, next: str | None = None, limit: int = 1 async def get_orders( # noqa: C901 self, request: Request, next: str | None = None, limit: int = 10 - ) -> OrderCollection[T]: + ) -> OrderCollection[OrderStatus]: links: list[Link] = [] orders_count: int | None = None match await self._get_orders(next, limit, request): @@ -273,13 +302,13 @@ async def get_orders( # noqa: C901 case _: raise AssertionError("Expected code to be unreachable") - return OrderCollection[T]( + return OrderCollection[OrderStatus]( features=orders, links=links, number_matched=orders_count, ) - async def get_order(self, order_id: str, request: Request) -> Order[T]: + async def get_order(self, order_id: str, request: Request) -> Order[OrderStatus]: """ Get details for order with `order_id`. """ @@ -308,7 +337,7 @@ async def get_order_statuses( request: Request, next: str | None = None, limit: int = 10, - ) -> OrderStatuses[T]: + ) -> OrderStatuses[OrderStatusBound]: links: list[Link] = [] match await self._get_order_statuses(order_id, next, limit, request): case Success(Some((statuses, maybe_pagination_token))): @@ -352,7 +381,7 @@ def generate_order_href(self, request: Request, order_id: str) -> URL: def generate_order_statuses_href(self, request: Request, order_id: str) -> URL: return self.url_for(request, f"{self.name}:{LIST_ORDER_STATUSES}", order_id=order_id) - def order_links(self, order: Order[T], request: Request) -> list[Link]: + def order_links(self, order: Order[Any], request: Request) -> list[Link]: return [ Link( href=self.generate_order_href(request, order.id), @@ -466,7 +495,7 @@ def opportunity_search_record_self_link( return json_link("self", self.generate_opportunity_search_record_href(request, opportunity_search_record.id)) @property - def _get_order_statuses(self) -> GetOrderStatuses[T]: + def _get_order_statuses(self) -> GetOrderStatuses[OrderStatusBound]: if not self.__get_order_statuses: raise AttributeError("Root router does not support order status history") return self.__get_order_statuses diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index 159b341..94f7eae 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -72,11 +72,11 @@ def new( ) -T = TypeVar("T", bound=OrderStatus) +OrderStatusBound = TypeVar("OrderStatusBound", bound=OrderStatus) -class OrderStatuses(BaseModel, Generic[T]): - statuses: list[T] +class OrderStatuses(BaseModel, Generic[OrderStatusBound]): + statuses: list[OrderStatusBound] links: list[Link] = Field(default_factory=list) @@ -87,10 +87,10 @@ class OrderSearchParameters(BaseModel): filter: CQL2Filter | None = None # type: ignore [type-arg] -class OrderProperties(BaseModel, Generic[T]): +class OrderProperties(BaseModel, Generic[OrderStatusBound]): product_id: str created: AwareDatetime - status: T + status: OrderStatusBound search_parameters: OrderSearchParameters opportunity_properties: dict[str, Any] @@ -100,7 +100,7 @@ class OrderProperties(BaseModel, Generic[T]): # derived from geojson_pydantic.Feature -class Order(_GeoJsonBase, Generic[T]): +class Order(_GeoJsonBase, Generic[OrderStatusBound]): # We need to enforce that orders have an id defined, as that is required to # retrieve them via the API id: StrictStr @@ -109,7 +109,7 @@ class Order(_GeoJsonBase, Generic[T]): stapi_version: str = STAPI_VERSION geometry: Geometry = Field(...) - properties: OrderProperties[T] = Field(...) + properties: OrderProperties[OrderStatusBound] = Field(...) links: list[Link] = Field(default_factory=list) @@ -125,15 +125,15 @@ def set_geometry(cls, geometry: Any) -> Any: # derived from geojson_pydantic.FeatureCollection -class OrderCollection(_GeoJsonBase, Generic[T]): +class OrderCollection(_GeoJsonBase, Generic[OrderStatusBound]): type: Literal["FeatureCollection"] = "FeatureCollection" - features: list[Order[T]] + features: list[Order[OrderStatusBound]] links: list[Link] = Field(default_factory=list) number_matched: int | None = Field( serialization_alias="numberMatched", default=None, exclude_if=lambda x: x is None ) - def __iter__(self) -> Iterator[Order[T]]: # type: ignore [override] + def __iter__(self) -> Iterator[Order[OrderStatusBound]]: # type: ignore [override] """iterate over features""" return iter(self.features) @@ -141,7 +141,7 @@ def __len__(self) -> int: """return features length""" return len(self.features) - def __getitem__(self, index: int) -> Order[T]: + def __getitem__(self, index: int) -> Order[OrderStatusBound]: """get feature at a given index""" return self.features[index] From daae6b8660d20806b371a9ab6dc89fc16b514d7a Mon Sep 17 00:00:00 2001 From: Phil Varner Date: Wed, 31 Dec 2025 12:59:22 -0500 Subject: [PATCH 03/11] rework imports, etc --- .../src/stapi_fastapi/backends/root_backend.py | 11 ++--------- .../src/stapi_fastapi/routers/root_router.py | 3 ++- stapi-pydantic/src/stapi_pydantic/__init__.py | 2 ++ stapi-pydantic/tests/test_json_schema.py | 2 +- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py index c71574e..51e0c87 100644 --- a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py +++ b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py @@ -1,17 +1,10 @@ from collections.abc import Callable, Coroutine -from typing import Any, Generic, Protocol, TypeVar +from typing import Any, Generic, Protocol from fastapi import Request from returns.maybe import Maybe from returns.result import ResultE -from stapi_pydantic import ( - OpportunitySearchRecord, - OpportunitySearchStatus, - Order, - OrderStatus, -) - -OrderStatusBound = TypeVar("OrderStatusBound", bound=OrderStatus) +from stapi_pydantic import OpportunitySearchRecord, OpportunitySearchStatus, Order, OrderStatusBound class GetOrders(Protocol, Generic[OrderStatusBound]): diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index edf1023..3946717 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -15,11 +15,12 @@ OpportunitySearchStatus, Order, OrderCollection, + OrderStatus, + OrderStatusBound, OrderStatuses, ProductsCollection, RootResponse, ) -from stapi_pydantic.order import OrderStatus, OrderStatusBound from stapi_fastapi.backends.root_backend import ( GetOpportunitySearchRecord, diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index 44ecbd0..4fd8e17 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -21,6 +21,7 @@ OrderProperties, OrderSearchParameters, OrderStatus, + OrderStatusBound, OrderStatusCode, OrderStatuses, ) @@ -52,6 +53,7 @@ "OrderStatus", "OrderStatusCode", "OrderStatuses", + "OrderStatusBound", "Prefer", "Product", "ProductsCollection", diff --git a/stapi-pydantic/tests/test_json_schema.py b/stapi-pydantic/tests/test_json_schema.py index e9bc38e..a787345 100644 --- a/stapi-pydantic/tests/test_json_schema.py +++ b/stapi-pydantic/tests/test_json_schema.py @@ -1,5 +1,5 @@ from pydantic import TypeAdapter -from stapi_pydantic.datetime_interval import DatetimeInterval +from stapi_pydantic import DatetimeInterval def test_datetime_interval() -> None: From 607496225de4e9429058f37e1fad4959d55ab581 Mon Sep 17 00:00:00 2001 From: Phil Varner Date: Wed, 31 Dec 2025 13:03:54 -0500 Subject: [PATCH 04/11] update docstrings --- stapi-fastapi/src/stapi_fastapi/backends/root_backend.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py index 51e0c87..4ee9644 100644 --- a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py +++ b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py @@ -8,7 +8,7 @@ class GetOrders(Protocol, Generic[OrderStatusBound]): - """Type alias for an async function that returns a list of existing Orders. + """Callable class wrapping an async method that returns a list of Orders. Args: next (str | None): A pagination token. @@ -34,7 +34,7 @@ async def __call__( class GetOrder(Protocol, Generic[OrderStatusBound]): - """Type alias for an async function that gets details for the order with `order_id`. + """Callable class wrapping an async method that gets details for the order with `order_id`. Args: order_id (str): The order ID. @@ -50,7 +50,7 @@ async def __call__(self, order_id: str, request: Request) -> ResultE[Maybe[Order class GetOrderStatuses(Protocol, Generic[OrderStatusBound]): - """Type alias for an async function that gets statuses for the order with `order_id`. + """Callable class wrapping an async method that gets statuses for the order with `order_id`. Args: order_id (str): The order ID. From ccb2e829322cd0563be3f4cc818f9c928580adb4 Mon Sep 17 00:00:00 2001 From: Phil Varner Date: Wed, 31 Dec 2025 13:38:04 -0500 Subject: [PATCH 05/11] Any type --- stapi-fastapi/src/stapi_fastapi/routers/root_router.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index 3946717..12a1639 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -73,7 +73,7 @@ def opportunity_search_record_self_link( def generate_opportunity_search_record_href(self, request: Request, search_record_id: str) -> URL: ... @abstractmethod - def order_links(self, order: Order[OrderStatusBound], request: Request) -> list[Link]: ... + def order_links(self, order: Order[Any], request: Request) -> list[Link]: ... @abstractmethod def generate_order_href(self, request: Request, order_id: str) -> URL: ... @@ -272,7 +272,7 @@ def get_products(self, request: Request, next: str | None = None, limit: int = 1 async def get_orders( # noqa: C901 self, request: Request, next: str | None = None, limit: int = 10 - ) -> OrderCollection[OrderStatus]: + ) -> OrderCollection[OrderStatusBound]: links: list[Link] = [] orders_count: int | None = None match await self._get_orders(next, limit, request): @@ -303,7 +303,7 @@ async def get_orders( # noqa: C901 case _: raise AssertionError("Expected code to be unreachable") - return OrderCollection[OrderStatus]( + return OrderCollection[OrderStatusBound]( features=orders, links=links, number_matched=orders_count, From 36e700e0ae4c01a9364aa14bad06031f7382d2a6 Mon Sep 17 00:00:00 2001 From: Phil Varner Date: Fri, 2 Jan 2026 12:46:39 -0500 Subject: [PATCH 06/11] convert to classes --- .../src/stapi_fastapi/backends/__init__.py | 2 - .../stapi_fastapi/backends/root_backend.py | 105 ++++++++-------- .../src/stapi_fastapi/routers/root_router.py | 9 +- stapi-fastapi/tests/application.py | 10 +- stapi-fastapi/tests/backends.py | 117 +++++++++--------- stapi-fastapi/tests/conftest.py | 15 +-- 6 files changed, 128 insertions(+), 130 deletions(-) diff --git a/stapi-fastapi/src/stapi_fastapi/backends/__init__.py b/stapi-fastapi/src/stapi_fastapi/backends/__init__.py index bdaed6a..5a584a9 100644 --- a/stapi-fastapi/src/stapi_fastapi/backends/__init__.py +++ b/stapi-fastapi/src/stapi_fastapi/backends/__init__.py @@ -7,7 +7,6 @@ from .root_backend import ( GetOpportunitySearchRecord, GetOpportunitySearchRecords, - GetOrder, GetOrders, GetOrderStatuses, ) @@ -17,7 +16,6 @@ "GetOpportunityCollection", "GetOpportunitySearchRecord", "GetOpportunitySearchRecords", - "GetOrder", "GetOrders", "GetOrderStatuses", "SearchOpportunities", diff --git a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py index 4ee9644..5756453 100644 --- a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py +++ b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py @@ -1,3 +1,4 @@ +from abc import abstractmethod from collections.abc import Callable, Coroutine from typing import Any, Generic, Protocol @@ -8,70 +9,74 @@ class GetOrders(Protocol, Generic[OrderStatusBound]): - """Callable class wrapping an async method that returns a list of Orders. + """Interface for getting a list of orders or a single order.""" - Args: - next (str | None): A pagination token. - limit (int): The maximum number of orders to return in a page. - request (Request): FastAPI's Request object. - - Returns: - A tuple containing a list of orders and a pagination token. - - - Should return returns.result.Success[tuple[list[Order], returns.maybe.Some[str]]] - if including a pagination token - - Should return returns.result.Success[tuple[list[Order], returns.maybe.Nothing]] - if not including a pagination token - - Returning returns.result.Failure[Exception] will result in a 500. - """ - - async def __call__( + @abstractmethod + async def get_orders( self, next: str | None, limit: int, request: Request, - ) -> ResultE[tuple[list[Order[OrderStatusBound]], Maybe[str], Maybe[int]]]: ... + ) -> ResultE[tuple[list[Order[OrderStatusBound]], Maybe[str], Maybe[int]]]: + """Get a list of Order objects. + Args: + next (str | None): A pagination token. + limit (int): The maximum number of orders to return in a page. + request (Request): FastAPI's Request object. -class GetOrder(Protocol, Generic[OrderStatusBound]): - """Callable class wrapping an async method that gets details for the order with `order_id`. + Returns: + A tuple containing a list of orders and a pagination token. - Args: - order_id (str): The order ID. - request (Request): FastAPI's Request object. + - Should return returns.result.Success[tuple[list[Order], returns.maybe.Some[str]]] + if including a pagination token + - Should return returns.result.Success[tuple[list[Order], returns.maybe.Nothing]] + if not including a pagination token + - Returning returns.result.Failure[Exception] will result in a 500. + """ - Returns: - - Should return returns.result.Success[returns.maybe.Some[Order]] if order is found. - - Should return returns.result.Success[returns.maybe.Nothing] if the order is not found or if access is denied. - - Returning returns.result.Failure[Exception] will result in a 500. - """ + @abstractmethod + async def get_order(self, order_id: str, request: Request) -> ResultE[Maybe[Order[OrderStatusBound]]]: + """Get details for the order with `order_id`. - async def __call__(self, order_id: str, request: Request) -> ResultE[Maybe[Order[OrderStatusBound]]]: ... + Args: + order_id (str): The order ID. + request (Request): FastAPI's Request object. + + Returns: + - Should return returns.result.Success[returns.maybe.Some[Order]] if order is found. + - Should return returns.result.Success[returns.maybe.Nothing] if the order is not found or if access is + denied. + - Returning returns.result.Failure[Exception] will result in a 500. + """ class GetOrderStatuses(Protocol, Generic[OrderStatusBound]): - """Callable class wrapping an async method that gets statuses for the order with `order_id`. - - Args: - order_id (str): The order ID. - next (str | None): A pagination token. - limit (int): The maximum number of statuses to return in a page. - request (Request): FastAPI's Request object. - - Returns: - A tuple containing a list of order statuses and a pagination token. - - - Should return returns.result.Success[returns.maybe.Some[tuple[list[OrderStatus], returns.maybe.Some[str]]] - if order is found and including a pagination token. - - Should return returns.result.Success[returns.maybe.Some[tuple[list[OrderStatus], returns.maybe.Nothing]]] - if order is found and not including a pagination token. - - Should return returns.result.Success[returns.maybe.Nothing] if the order is not found or if access is denied. - - Returning returns.result.Failure[Exception] will result in a 500. - """ - - async def __call__( + """Callable class wrapping an async method that gets statuses for the order with `order_id`.""" + + @abstractmethod + async def get_order_statuses( self, order_id: str, _next: str | None, limit: int, request: Request - ) -> ResultE[Maybe[tuple[list[OrderStatusBound], Maybe[str]]]]: ... + ) -> ResultE[Maybe[tuple[list[OrderStatusBound], Maybe[str]]]]: + """Method that gets statuses for the order with `order_id`. + + Args: + order_id (str): The order ID. + next (str | None): A pagination token. + limit (int): The maximum number of statuses to return in a page. + request (Request): FastAPI's Request object. + + Returns: + A tuple containing a list of order statuses and a pagination token. + + - Should return returns.result.Success[returns.maybe.Some[tuple[list[OrderStatus], returns.maybe.Some[str]]] + if order is found and including a pagination token. + - Should return returns.result.Success[returns.maybe.Some[tuple[list[OrderStatus], returns.maybe.Nothing]]] + if order is found and not including a pagination token. + - Should return returns.result.Success[returns.maybe.Nothing] if the order is not found or if access is + denied. + - Returning returns.result.Failure[Exception] will result in a 500. + """ GetOpportunitySearchRecords = Callable[ diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index 12a1639..98e0347 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -26,7 +26,6 @@ GetOpportunitySearchRecord, GetOpportunitySearchRecords, GetOpportunitySearchRecordStatuses, - GetOrder, GetOrders, GetOrderStatuses, ) @@ -83,7 +82,6 @@ class RootRouter(StapiFastapiBaseRouter, RootProvider, Generic[OrderStatusBound] def __init__( self, get_orders: GetOrders[OrderStatusBound], - get_order: GetOrder[OrderStatusBound], get_order_statuses: GetOrderStatuses[OrderStatusBound] | None = None, get_opportunity_search_records: GetOpportunitySearchRecords | None = None, get_opportunity_search_record: GetOpportunitySearchRecord | None = None, @@ -100,7 +98,6 @@ def __init__( _conformances = set(conformances) self._get_orders = get_orders - self._get_order = get_order 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 @@ -275,7 +272,7 @@ async def get_orders( # noqa: C901 ) -> OrderCollection[OrderStatusBound]: links: list[Link] = [] orders_count: int | None = None - match await self._get_orders(next, limit, request): + match await self._get_orders.get_orders(next, limit, request): case Success((orders, maybe_pagination_token, maybe_orders_count)): for order in orders: order.links.extend(self.order_links(order, request)) @@ -313,7 +310,7 @@ async def get_order(self, order_id: str, request: Request) -> Order[OrderStatus] """ Get details for order with `order_id`. """ - match await self._get_order(order_id, request): + match await self._get_orders.get_order(order_id, request): case Success(Some(order)): order.links.extend(self.order_links(order, request)) return order # type: ignore @@ -340,7 +337,7 @@ async def get_order_statuses( limit: int = 10, ) -> OrderStatuses[OrderStatusBound]: links: list[Link] = [] - match await self._get_order_statuses(order_id, next, limit, request): + match await self._get_order_statuses.get_order_statuses(order_id, next, limit, request): case Success(Some((statuses, maybe_pagination_token))): links.append(self.order_statuses_link(request, order_id)) match maybe_pagination_token: diff --git a/stapi-fastapi/tests/application.py b/stapi-fastapi/tests/application.py index 6a34a5d..fa6f526 100644 --- a/stapi-fastapi/tests/application.py +++ b/stapi-fastapi/tests/application.py @@ -7,11 +7,10 @@ from stapi_fastapi.routers.root_router import RootRouter from tests.backends import ( + MockGetOrders, + MockGetOrderStatuses, mock_get_opportunity_search_record, mock_get_opportunity_search_records, - mock_get_order, - mock_get_order_statuses, - mock_get_orders, ) from tests.shared import ( InMemoryOpportunityDB, @@ -30,9 +29,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: root_router = RootRouter( - get_orders=mock_get_orders, - get_order=mock_get_order, - get_order_statuses=mock_get_order_statuses, + get_orders=MockGetOrders(), + get_order_statuses=MockGetOrderStatuses(), get_opportunity_search_records=mock_get_opportunity_search_records, get_opportunity_search_record=mock_get_opportunity_search_record, conformances=[API.core], diff --git a/stapi-fastapi/tests/backends.py b/stapi-fastapi/tests/backends.py index f902fb1..9ffd366 100644 --- a/stapi-fastapi/tests/backends.py +++ b/stapi-fastapi/tests/backends.py @@ -4,6 +4,7 @@ from fastapi import Request from returns.maybe import Maybe, Nothing, Some from returns.result import Failure, ResultE, Success +from stapi_fastapi.backends.root_backend import GetOrders, GetOrderStatuses from stapi_fastapi.routers.product_router import ProductRouter from stapi_pydantic import ( Opportunity, @@ -21,63 +22,65 @@ ) -async def mock_get_orders( - next: str | None, - limit: int, - request: Request, -) -> ResultE[tuple[list[Order], Maybe[str], Maybe[int]]]: - """ - Return orders from backend. Handle pagination/limit if applicable - """ - count = 314 - try: - start = 0 - limit = min(limit, 100) - order_ids = [*request.state._orders_db._orders.keys()] - - if next: - start = order_ids.index(next) - end = start + limit - ids = order_ids[start:end] - orders = [request.state._orders_db.get_order(order_id) for order_id in ids] - - if end > 0 and end < len(order_ids): - return Success((orders, Some(request.state._orders_db._orders[order_ids[end]].id), Some(count))) - return Success((orders, Nothing, Some(count))) - except Exception as e: - return Failure(e) - - -async def mock_get_order(order_id: str, request: Request) -> ResultE[Maybe[Order]]: - """ - Show details for order with `order_id`. - """ - try: - return Success(Maybe.from_optional(request.state._orders_db.get_order(order_id))) - except Exception as e: - return Failure(e) - - -async def mock_get_order_statuses( - order_id: str, next: str | None, limit: int, request: Request -) -> ResultE[Maybe[tuple[list[OrderStatus], Maybe[str]]]]: - try: - start = 0 - limit = min(limit, 100) - statuses = request.state._orders_db.get_order_statuses(order_id) - if statuses is None: - return Success(Nothing) - - if next: - start = int(next) - end = start + limit - stati = statuses[start:end] - - if end > 0 and end < len(statuses): - return Success(Some((stati, Some(str(end))))) - return Success(Some((stati, Nothing))) - except Exception as e: - return Failure(e) +class MockGetOrders(GetOrders): + async def get_orders( + self, + next: str | None, + limit: int, + request: Request, + ) -> ResultE[tuple[list[Order], Maybe[str], Maybe[int]]]: + """ + Return orders from backend. Handle pagination/limit if applicable + """ + count = 314 + try: + start = 0 + limit = min(limit, 100) + order_ids = [*request.state._orders_db._orders.keys()] + + if next: + start = order_ids.index(next) + end = start + limit + ids = order_ids[start:end] + orders = [request.state._orders_db.get_order(order_id) for order_id in ids] + + if end > 0 and end < len(order_ids): + return Success((orders, Some(request.state._orders_db._orders[order_ids[end]].id), Some(count))) + return Success((orders, Nothing, Some(count))) + except Exception as e: + return Failure(e) + + async def get_order(self, order_id: str, request: Request) -> ResultE[Maybe[Order]]: + """ + Show details for order with `order_id`. + """ + try: + return Success(Maybe.from_optional(request.state._orders_db.get_order(order_id))) + except Exception as e: + return Failure(e) + + +class MockGetOrderStatuses(GetOrderStatuses): + async def get_order_statuses( + self, order_id: str, next: str | None, limit: int, request: Request + ) -> ResultE[Maybe[tuple[list[OrderStatus], Maybe[str]]]]: + try: + start = 0 + limit = min(limit, 100) + statuses = request.state._orders_db.get_order_statuses(order_id) + if statuses is None: + return Success(Nothing) + + if next: + start = int(next) + end = start + limit + stati = statuses[start:end] + + if end > 0 and end < len(statuses): + return Success(Some((stati, Some(str(end))))) + return Success(Some((stati, Nothing))) + except Exception as e: + return Failure(e) async def mock_create_order(product_router: ProductRouter, payload: OrderPayload, request: Request) -> ResultE[Order]: diff --git a/stapi-fastapi/tests/conftest.py b/stapi-fastapi/tests/conftest.py index b0be83c..353edb2 100644 --- a/stapi-fastapi/tests/conftest.py +++ b/stapi-fastapi/tests/conftest.py @@ -17,12 +17,11 @@ ) from .backends import ( + MockGetOrders, + MockGetOrderStatuses, 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, @@ -72,9 +71,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: pass root_router = RootRouter( - get_orders=mock_get_orders, - get_order=mock_get_order, - get_order_statuses=mock_get_order_statuses, + get_orders=MockGetOrders(), + get_order_statuses=MockGetOrderStatuses(), conformances=[API.core], ) @@ -107,9 +105,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: pass root_router = RootRouter( - get_orders=mock_get_orders, - get_order=mock_get_order, - get_order_statuses=mock_get_order_statuses, + get_orders=MockGetOrders(), + get_order_statuses=MockGetOrderStatuses(), 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, From 2c96ecf7a5387bf717a1a1118739d84320624f64 Mon Sep 17 00:00:00 2001 From: Phil Varner Date: Mon, 5 Jan 2026 10:58:27 -0500 Subject: [PATCH 07/11] add osb --- stapi-fastapi/src/stapi_fastapi/routers/product_router.py | 4 ++-- stapi-fastapi/src/stapi_fastapi/routers/root_router.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index a8c14d6..46376b1 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -25,7 +25,7 @@ OpportunitySearchRecord, Order, OrderPayload, - OrderStatus, + OrderStatusBound, Prefer, ) from stapi_pydantic import ( @@ -149,7 +149,7 @@ async def _create_order( payload: OrderPayload, # type: ignore request: Request, response: Response, - ) -> Order[OrderStatus]: + ) -> Order[OrderStatusBound]: return await self.create_order(payload, request, response) _create_order.__annotations__["payload"] = OrderPayload[ diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index 98e0347..4725b35 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -15,7 +15,6 @@ OpportunitySearchStatus, Order, OrderCollection, - OrderStatus, OrderStatusBound, OrderStatuses, ProductsCollection, @@ -306,7 +305,7 @@ async def get_orders( # noqa: C901 number_matched=orders_count, ) - async def get_order(self, order_id: str, request: Request) -> Order[OrderStatus]: + async def get_order(self, order_id: str, request: Request) -> Order[OrderStatusBound]: """ Get details for order with `order_id`. """ From d3fab10b3d486f672faf3f22d18338cb0c2d5ac4 Mon Sep 17 00:00:00 2001 From: Phil Varner Date: Mon, 5 Jan 2026 11:08:52 -0500 Subject: [PATCH 08/11] change next to next_ --- stapi-fastapi/src/stapi_fastapi/backends/root_backend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py index 5756453..936ef62 100644 --- a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py +++ b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py @@ -14,7 +14,7 @@ class GetOrders(Protocol, Generic[OrderStatusBound]): @abstractmethod async def get_orders( self, - next: str | None, + next_: str | None, limit: int, request: Request, ) -> ResultE[tuple[list[Order[OrderStatusBound]], Maybe[str], Maybe[int]]]: @@ -56,7 +56,7 @@ class GetOrderStatuses(Protocol, Generic[OrderStatusBound]): @abstractmethod async def get_order_statuses( - self, order_id: str, _next: str | None, limit: int, request: Request + self, order_id: str, next_: str | None, limit: int, request: Request ) -> ResultE[Maybe[tuple[list[OrderStatusBound], Maybe[str]]]]: """Method that gets statuses for the order with `order_id`. From 1968d6dd65d99eff4bd72907547151a9db142382 Mon Sep 17 00:00:00 2001 From: Phil Varner Date: Mon, 5 Jan 2026 11:31:33 -0500 Subject: [PATCH 09/11] change names back to net --- stapi-fastapi/src/stapi_fastapi/backends/root_backend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py index 936ef62..a1425ee 100644 --- a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py +++ b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py @@ -14,7 +14,7 @@ class GetOrders(Protocol, Generic[OrderStatusBound]): @abstractmethod async def get_orders( self, - next_: str | None, + next: str | None, limit: int, request: Request, ) -> ResultE[tuple[list[Order[OrderStatusBound]], Maybe[str], Maybe[int]]]: @@ -56,7 +56,7 @@ class GetOrderStatuses(Protocol, Generic[OrderStatusBound]): @abstractmethod async def get_order_statuses( - self, order_id: str, next_: str | None, limit: int, request: Request + self, order_id: str, next: str | None, limit: int, request: Request ) -> ResultE[Maybe[tuple[list[OrderStatusBound], Maybe[str]]]]: """Method that gets statuses for the order with `order_id`. From 41549bf4f78c5f8b98b1cb212b14bcf756db8922 Mon Sep 17 00:00:00 2001 From: Phil Varner Date: Mon, 5 Jan 2026 12:53:36 -0500 Subject: [PATCH 10/11] change conformances --- stapi-fastapi/src/stapi_fastapi/routers/product_router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index 46376b1..c9d9558 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -100,7 +100,7 @@ def __init__( # noqa self.product = product self.root_provider = root_provider self.conformances_support: ConformancesSupport = root_provider - self.conformances = build_conformances(product, root_provider) + self.conformances = build_conformances(product, self.conformances_support) self.add_api_route( path="", From 9337a78e6e0ed023650e9fffabde868c5dd7480b Mon Sep 17 00:00:00 2001 From: Phil Varner Date: Mon, 5 Jan 2026 14:41:36 -0500 Subject: [PATCH 11/11] update changelog --- stapi-fastapi/CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index 23e23da..c8c7cd0 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- The GetOrder, GetOrders, and GetOrderStatuses Callable aliases are refactored into two classes (GetOrders and GetOrderStatus) that + must be implemented instead. These are genericized to allow appropriate type annotations such that FastAPI will correctly serialize + subclasses of OrderStatus. +- The use of the RootRouter class in ProductRouter are refactored to use two Protocols (ConformancesSupport and RootProvider), so as + to decouple the actual (generified) RootRouter from the uses in that class. + ## [0.8.0] - 2025-12-18 ### Added