From 45307b7735e1d6e62ace69d50a29ad7f8ba6ae7f Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Tue, 4 Feb 2025 08:33:12 -0600 Subject: [PATCH 01/21] feat: add async opportunity models --- src/stapi_fastapi/models/conformance.py | 1 + src/stapi_fastapi/models/opportunity.py | 33 ++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/stapi_fastapi/models/conformance.py b/src/stapi_fastapi/models/conformance.py index 9cfddbf..d211fc8 100644 --- a/src/stapi_fastapi/models/conformance.py +++ b/src/stapi_fastapi/models/conformance.py @@ -2,6 +2,7 @@ CORE = "https://stapi.example.com/v0.1.0/core" OPPORTUNITIES = "https://stapi.example.com/v0.1.0/opportunities" +ASYNC_OPPORTUNITIES = "https://stapi.example.com/v0.1.0/async-opportunities" class Conformance(BaseModel): diff --git a/src/stapi_fastapi/models/opportunity.py b/src/stapi_fastapi/models/opportunity.py index 930cfcf..4f313c4 100644 --- a/src/stapi_fastapi/models/opportunity.py +++ b/src/stapi_fastapi/models/opportunity.py @@ -1,8 +1,9 @@ +from enum import StrEnum from typing import Literal, TypeVar from geojson_pydantic import Feature, FeatureCollection from geojson_pydantic.geometries import Geometry -from pydantic import BaseModel, ConfigDict, Field +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field from stapi_fastapi.models.shared import Link from stapi_fastapi.types.datetime_interval import DatetimeInterval @@ -36,3 +37,33 @@ class Opportunity(Feature[G, P]): class OpportunityCollection(FeatureCollection[Opportunity[G, P]]): type: Literal["FeatureCollection"] = "FeatureCollection" links: list[Link] = Field(default_factory=list) + id: str | None = None + + +class OpportunitySearchStatusCode(StrEnum): + received = "received" + in_progress = "in_progress" + failed = "failed" + canceled = "canceled" + completed = "completed" + + +class OpportunitySearchStatus(BaseModel): + timestamp: AwareDatetime + status_code: OpportunitySearchStatusCode + reason_code: str | None = None + reason_text: str | None = None + links: list[Link] = Field(default_factory=list) + + +class OpportunitySearchRecord(BaseModel): + id: str + product_id: str + request: OpportunityRequest + status: OpportunitySearchStatus + links: list[Link] = Field(default_factory=list) + + +class Prefer(StrEnum): + respond_async = "respond-async" + wait = "wait" From 9cdf3f21b8f440c4984552037ffc8300e09197ec Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Tue, 4 Feb 2025 08:49:18 -0600 Subject: [PATCH 02/21] feat: update backends for asynchronous opportunity searches --- src/stapi_fastapi/backends/__init__.py | 19 ++++++- src/stapi_fastapi/backends/product_backend.py | 49 ++++++++++++++++++- src/stapi_fastapi/backends/root_backend.py | 39 ++++++++++++++- 3 files changed, 102 insertions(+), 5 deletions(-) diff --git a/src/stapi_fastapi/backends/__init__.py b/src/stapi_fastapi/backends/__init__.py index b2fb899..bdaed6a 100644 --- a/src/stapi_fastapi/backends/__init__.py +++ b/src/stapi_fastapi/backends/__init__.py @@ -1,10 +1,25 @@ -from .product_backend import CreateOrder, SearchOpportunities -from .root_backend import GetOrder, GetOrders, GetOrderStatuses +from .product_backend import ( + CreateOrder, + GetOpportunityCollection, + SearchOpportunities, + SearchOpportunitiesAsync, +) +from .root_backend import ( + GetOpportunitySearchRecord, + GetOpportunitySearchRecords, + GetOrder, + GetOrders, + GetOrderStatuses, +) __all__ = [ "CreateOrder", + "GetOpportunityCollection", + "GetOpportunitySearchRecord", + "GetOpportunitySearchRecords", "GetOrder", "GetOrders", "GetOrderStatuses", "SearchOpportunities", + "SearchOpportunitiesAsync", ] diff --git a/src/stapi_fastapi/backends/product_backend.py b/src/stapi_fastapi/backends/product_backend.py index f7bc3c0..2288c6c 100644 --- a/src/stapi_fastapi/backends/product_backend.py +++ b/src/stapi_fastapi/backends/product_backend.py @@ -6,7 +6,12 @@ from returns.maybe import Maybe from returns.result import ResultE -from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest +from stapi_fastapi.models.opportunity import ( + Opportunity, + OpportunityCollection, + OpportunityRequest, + OpportunitySearchRecord, +) from stapi_fastapi.models.order import Order, OrderPayload from stapi_fastapi.routers.product_router import ProductRouter @@ -37,6 +42,48 @@ returns.result.Failure[stapi_fastapi.exceptions.ConstraintsException] if not valid. """ +SearchOpportunitiesAsync = Callable[ + [ProductRouter, OpportunityRequest, Request], + Coroutine[Any, Any, ResultE[OpportunitySearchRecord]], +] +""" +Type alias for an async function that starts an asynchronous search for ordering +opportunities for the given search parameters. + +Args: + product_router (ProductRouter): The product router. + search (OpportunityRequest): The search parameters. + request (Request): FastAPI's Request object. + +Returns: + - Should return returns.result.Success[OpportunitySearchRecord] + - Returning returns.result.Failure[Exception] will result in a 500. + +Backends must validate search constraints and return +returns.result.Failure[stapi_fastapi.exceptions.ConstraintsException] if not valid. +""" + +GetOpportunityCollection = Callable[ + [ProductRouter, str, Request], + Coroutine[Any, Any, ResultE[Maybe[OpportunityCollection]]], +] +""" +Type alias for an async function that retrieves the opportunity collection with +`opportunity_collection_id`. + +The opportunity collection is generated by an asynchronous opportunity search. + +Args: + product_router (ProductRouter): The product router. + opportunity_collection_id (str): The ID of the opportunity collection. + request (Request): FastAPI's Request object. + +Returns: + - Should return returns.result.Success[returns.maybe.Some[OpportunityCollection]] if the opportunity collection is found. + - Should return returns.result.Success[returns.maybe.Nothing] if the opportunity collection is not found or if access is denied. + - Returning returns.result.Failure[Exception] will result in a 500. +""" + CreateOrder = Callable[ [ProductRouter, OrderPayload, Request], Coroutine[Any, Any, ResultE[Order]] ] diff --git a/src/stapi_fastapi/backends/root_backend.py b/src/stapi_fastapi/backends/root_backend.py index 5582f11..be8dc8e 100644 --- a/src/stapi_fastapi/backends/root_backend.py +++ b/src/stapi_fastapi/backends/root_backend.py @@ -4,6 +4,7 @@ from returns.maybe import Maybe from returns.result import ResultE +from stapi_fastapi.models.opportunity import OpportunitySearchRecord from stapi_fastapi.models.order import ( Order, OrderStatus, @@ -39,8 +40,7 @@ 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. + - 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. """ @@ -69,3 +69,38 @@ - Should return returns.result.Failure[Exception] if the order is not found or if access is denied. - Returning returns.result.Failure[Exception] will result in a 500. """ + +GetOpportunitySearchRecords = Callable[ + [Request, str | None, int], + Coroutine[Any, Any, ResultE[tuple[list[OpportunitySearchRecord], Maybe[str]]]], +] +""" +Type alias for an async function that gets OpportunitySearchRecords for all products. + +Args: + request (Request): FastAPI's Request object. + next (str | None): A pagination token. + limit (int): The maximum number of search records to return in a page. + +Returns: + - Should return returns.result.Success[tuple[list[OpportunitySearchRecord], returns.maybe.Some[str]]] if including a pagination token + - Should return returns.result.Success[tuple[list[OpportunitySearchRecord], returns.maybe.Nothing]] if not including a pagination token + - Returning returns.result.Failure[Exception] will result in a 500. +""" + +GetOpportunitySearchRecord = Callable[ + [str, Request], Coroutine[Any, Any, ResultE[Maybe[OpportunitySearchRecord]]] +] +""" +Type alias for an async function that gets the OpportunitySearchRecord with +`search_record_id`. + +Args: + search_record_id (str): The ID of the OpportunitySearchRecord. + request (Request): FastAPI's Request object. + +Returns: + - Should return returns.result.Success[returns.maybe.Some[OpportunitySearchRecord]] if the search record is found. + - Should return returns.result.Success[returns.maybe.Nothing] if the search record is not found or if access is denied. + - Returning returns.result.Failure[Exception] will result in a 500. +""" From dd7c7ad8abc1b7ac67b7d5c5152d14ed1ff96560 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Tue, 4 Feb 2025 08:56:46 -0600 Subject: [PATCH 03/21] feat: update product model to support async and optional opportunity searching --- src/stapi_fastapi/models/product.py | 39 ++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/src/stapi_fastapi/models/product.py b/src/stapi_fastapi/models/product.py index d81e79f..e570197 100644 --- a/src/stapi_fastapi/models/product.py +++ b/src/stapi_fastapi/models/product.py @@ -12,7 +12,9 @@ if TYPE_CHECKING: from stapi_fastapi.backends.product_backend import ( CreateOrder, + GetOpportunityCollection, SearchOpportunities, + SearchOpportunitiesAsync, ) @@ -54,33 +56,42 @@ class Product(BaseModel): _opportunity_properties: type[OpportunityProperties] _order_parameters: type[OrderParameters] _create_order: CreateOrder - _search_opportunities: SearchOpportunities def __init__( self, *args, - create_order: CreateOrder, - search_opportunities: SearchOpportunities, constraints: type[Constraints], opportunity_properties: type[OpportunityProperties], order_parameters: type[OrderParameters], + create_order: CreateOrder, + search_opportunities: SearchOpportunities | None = None, + search_opportunities_async: SearchOpportunitiesAsync | None = None, + get_opportunity_collection: GetOpportunityCollection | None = None, **kwargs, ) -> None: super().__init__(*args, **kwargs) - self._create_order = create_order - self._search_opportunities = search_opportunities + + if bool(search_opportunities_async) != bool(get_opportunity_collection): + raise ValueError( + "Both the `search_opportunities_async` and `get_opportunity_collection` " + "arguments must be provided if either is provided" + ) + self._constraints = constraints self._opportunity_properties = opportunity_properties self._order_parameters = order_parameters + self._create_order = create_order + if search_opportunities is not None: + self._search_opportunities = search_opportunities + if search_opportunities_async is not None: + self._search_opportunities_async = search_opportunities_async + if get_opportunity_collection is not None: + self._get_opportunity_collection = get_opportunity_collection @property def create_order(self: Self) -> CreateOrder: return self._create_order - @property - def search_opportunities(self: Self) -> SearchOpportunities: - return self._search_opportunities - @property def constraints(self: Self) -> type[Constraints]: return self._constraints @@ -93,6 +104,16 @@ def opportunity_properties(self: Self) -> type[OpportunityProperties]: def order_parameters(self: Self) -> type[OrderParameters]: return self._order_parameters + @property + def supports_opportunity_search(self: Self) -> bool: + return hasattr(self, "_search_opportunities") + + @property + def supports_async_opportunity_search(self: Self) -> bool: + return hasattr(self, "_search_opportunities_async") and hasattr( + self, "_get_opportunity_collection" + ) + def with_links(self: Self, links: list[Link] | None = None) -> Self: if not links: return self From 7a000952bee212cf63528291c330e8a4ebe66d88 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Tue, 4 Feb 2025 09:43:33 -0600 Subject: [PATCH 04/21] feat: Add async and optional opportunity search to routers --- src/stapi_fastapi/models/opportunity.py | 5 + src/stapi_fastapi/routers/product_router.py | 260 ++++++++++++++++---- src/stapi_fastapi/routers/root_router.py | 227 ++++++++++++++--- 3 files changed, 399 insertions(+), 93 deletions(-) diff --git a/src/stapi_fastapi/models/opportunity.py b/src/stapi_fastapi/models/opportunity.py index 4f313c4..5061709 100644 --- a/src/stapi_fastapi/models/opportunity.py +++ b/src/stapi_fastapi/models/opportunity.py @@ -64,6 +64,11 @@ class OpportunitySearchRecord(BaseModel): links: list[Link] = Field(default_factory=list) +class OpportunitySearchRecords(BaseModel): + records: list[OpportunitySearchRecord] + links: list[Link] = Field(default_factory=list) + + class Prefer(StrEnum): respond_async = "respond-async" wait = "wait" diff --git a/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py index 8dbd031..ead25bd 100644 --- a/src/stapi_fastapi/routers/product_router.py +++ b/src/stapi_fastapi/routers/product_router.py @@ -4,16 +4,27 @@ import traceback from typing import TYPE_CHECKING, Annotated, Self -from fastapi import APIRouter, Body, HTTPException, Request, Response, status +from fastapi import ( + APIRouter, + Body, + Depends, + Header, + HTTPException, + Request, + Response, + status, +) from geojson_pydantic.geometries import Geometry -from returns.maybe import Some +from returns.maybe import Maybe, Some from returns.result import Failure, Success from stapi_fastapi.constants import TYPE_JSON -from stapi_fastapi.exceptions import ConstraintsException +from stapi_fastapi.exceptions import ConstraintsException, NotFoundException from stapi_fastapi.models.opportunity import ( OpportunityCollection, OpportunityRequest, + OpportunitySearchRecord, + Prefer, ) from stapi_fastapi.models.order import Order, OrderPayload from stapi_fastapi.models.product import Product @@ -27,15 +38,38 @@ logger = logging.getLogger(__name__) +def get_preference(prefer: str | None = Header(None)) -> str | None: + if prefer is None: + return None + + if prefer not in Prefer: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid Prefer header value: {prefer}", + ) + + return prefer + + class ProductRouter(APIRouter): def __init__( - self, + self: Self, product: Product, root_router: RootRouter, *args, **kwargs, ) -> 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 " + f"the root router does", + ) + self.product = product self.root_router = root_router @@ -48,21 +82,6 @@ def __init__( tags=["Products"], ) - self.add_api_route( - path="/opportunities", - endpoint=self.search_opportunities, - name=f"{self.root_router.name}:{self.product.id}:search-opportunities", - methods=["POST"], - response_class=GeoJSONResponse, - # unknown why mypy can't see the constraints property on Product, ignoring - response_model=OpportunityCollection[ - Geometry, - self.product.opportunity_properties, # type: ignore - ], - summary="Search Opportunities for the product", - tags=["Products"], - ) - self.add_api_route( path="/constraints", endpoint=self.get_product_constraints, @@ -110,7 +129,43 @@ async def _create_order( tags=["Products"], ) - def get_product(self, request: Request) -> Product: + if product.supports_opportunity_search: + self.add_api_route( + path="/opportunities", + endpoint=self.search_opportunities, + name=f"{self.root_router.name}:{self.product.id}:search-opportunities", + methods=["POST"], + response_class=GeoJSONResponse, + # unknown why mypy can't see the constraints property on Product, ignoring + response_model=OpportunityCollection[ + Geometry, + self.product.opportunity_properties, # type: ignore + ], + summary="Search Opportunities for the product", + tags=["Products"], + ) + + if root_router.supports_async_opportunity_search: + self.add_api_route( + path="/opportunities", + endpoint=self.search_opportunities_async, + name=f"{self.root_router.name}:{self.product.id}:search-opportunities", + methods=["POST"], + status_code=status.HTTP_201_CREATED, + summary="Search Opportunities for the product", + tags=["Products"], + ) + + 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", + methods=["GET"], + summary="Get an Opportunity Collection by ID", + tags=["Products"], + ) + + def get_product(self: Self, request: Request) -> Product: return self.product.with_links( links=[ Link( @@ -162,47 +217,108 @@ def get_product(self, request: Request) -> Product: ) async def search_opportunities( - self, + self: Self, search: OpportunityRequest, request: Request, + response: GeoJSONResponse, + prefer: str | None = Depends(get_preference), next: Annotated[str | None, Body()] = None, limit: Annotated[int, Body()] = 10, ) -> OpportunityCollection: """ Explore the opportunities available for a particular set of constraints """ - links: list[Link] = [] - match await self.product._search_opportunities( - self, - search, - next, - limit, - request, + if ( + not self.root_router.supports_async_opportunity_search + or prefer is Prefer.wait ): - case Success((features, Some(pagination_token))): - links.append(self.order_link(request)) - body = { - "search": search.model_dump(mode="json"), - "next": pagination_token, - "limit": limit, - } - links.append(self.pagination_link(request, body)) - case Success((features, Nothing)): # noqa: F841 - links.append(self.order_link(request)) - case Failure(e) if isinstance(e, ConstraintsException): - raise e - case Failure(e): - logger.error( - "An error occurred while searching opportunities: %s", - traceback.format_exception(e), - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error searching opportunities", - ) - case x: - raise AssertionError(f"Expected code to be unreachable {x}") - return OpportunityCollection(features=features, links=links) + links: list[Link] = [] + match await self.product._search_opportunities( + self, + search, + next, + limit, + request, + ): + case Success((features, Some(pagination_token))): + links.append(self.order_link(request)) + body = { + "search": search.model_dump(mode="json"), + "next": pagination_token, + "limit": limit, + } + links.append(self.pagination_link(request, body)) + case Success((features, Nothing)): # noqa: F841 + links.append(self.order_link(request)) + case Failure(e) if isinstance(e, ConstraintsException): + raise e + case Failure(e): + logger.error( + "An error occurred while searching opportunities: %s", + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error searching opportunities", + ) + case x: + raise AssertionError(f"Expected code to be unreachable {x}") + + if ( + prefer is Prefer.wait + and self.root_router.supports_async_opportunity_search + ): + response.headers["Preference-Applied"] = "wait" + + return OpportunityCollection(features=features, links=links) + + raise AssertionError("Expected code to be unreachable") + + async def search_opportunities_async( + self: Self, + search: OpportunityRequest, + request: Request, + response: Response, + prefer: str | None = Depends(get_preference), + ) -> OpportunitySearchRecord: + """ + Initiate an asynchronous search for opportunities. + + TODO: Do I need a location header somewhere? + """ + if ( + prefer is None + or prefer is Prefer.respond_async + or (prefer is Prefer.wait and not self.product.supports_opportunity_search) + ): + match await self.product._search_opportunities_async(self, search, request): + case Success(search_record): + self.root_router.add_opportunity_search_record_self_link( + search_record, request + ) + response.headers["Location"] = str( + self.root_router.generate_opportunity_search_record_href( + request, search_record.id + ) + ) + if prefer is not None: + response.headers["Preference-Applied"] = "respond-async" + return search_record + case Failure(e) if isinstance(e, ConstraintsException): + raise e + case Failure(e): + logger.error( + "An error occurred while initiating an asynchronous opportunity search: %s", + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error initiating an asynchronous opportunity search", + ) + case x: + raise AssertionError(f"Expected code to be unreachable: {x}") + + raise AssertionError("Expected code to be unreachable") def get_product_constraints(self: Self) -> JsonSchemaModel: """ @@ -266,3 +382,43 @@ def pagination_link(self, request: Request, body: dict[str, str | dict]): method="POST", body=body, ) + + async def get_opportunity_collection( + self: Self, opportunity_collection_id: str, request: Request + ) -> Response: + """ + Fetch an opportunity collection generated by an asynchronous opportunity search. + """ + match await self.product._get_opportunity_collection( + self, + opportunity_collection_id, + request, + ): + case Success(Some(opportunity_collection)): + opportunity_collection.links.append( + Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:get-opportunity-collection", + opportunity_collection_id=opportunity_collection_id, + ), + ), + rel="self", + type=TYPE_JSON, + ), + ) + return GeoJSONResponse(content=opportunity_collection.model_dump_json()) + case Success(Maybe.empty): + raise NotFoundException("Opportunity Collection not found") + case Failure(e): + logger.error( + "An error occurred while fetching opportunity collection: '%s': %s", + opportunity_collection_id, + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error fetching Opportunity Collection", + ) + case x: + raise AssertionError(f"Expected code to be unreachable {x}") diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index 4aed5da..0f88f63 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -7,10 +7,24 @@ from returns.maybe import Maybe, Some from returns.result import Failure, Success -from stapi_fastapi.backends.root_backend import GetOrder, GetOrders, GetOrderStatuses +from stapi_fastapi.backends.root_backend import ( + GetOpportunitySearchRecord, + GetOpportunitySearchRecords, + GetOrder, + GetOrders, + GetOrderStatuses, +) from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON from stapi_fastapi.exceptions import NotFoundException -from stapi_fastapi.models.conformance import CORE, Conformance +from stapi_fastapi.models.conformance import ( + ASYNC_OPPORTUNITIES, + CORE, + Conformance, +) +from stapi_fastapi.models.opportunity import ( + OpportunitySearchRecord, + OpportunitySearchRecords, +) from stapi_fastapi.models.order import ( Order, OrderCollection, @@ -27,10 +41,12 @@ class RootRouter(APIRouter): def __init__( - self, + self: Self, get_orders: GetOrders, get_order: GetOrder, get_order_statuses: GetOrderStatuses, + get_opportunity_search_records: GetOpportunitySearchRecords | None = None, + get_opportunity_search_record: GetOpportunitySearchRecord | None = None, conformances: list[str] = [CORE], name: str = "root", openapi_endpoint_name: str = "openapi", @@ -39,11 +55,24 @@ def __init__( **kwargs, ) -> None: super().__init__(*args, **kwargs) + + if ASYNC_OPPORTUNITIES in conformances and ( + not get_opportunity_search_records or not get_opportunity_search_record + ): + raise ValueError( + "`get_opportunity_search_records` and `get_opportunity_search_record` " + "are required when advertising async opportunity search conformance" + ) + self._get_orders = get_orders self._get_order = get_order self._get_order_statuses = get_order_statuses - self.name = name + if get_opportunity_search_records is not None: + self._get_opportunity_search_records = get_opportunity_search_records + if get_opportunity_search_record is not None: + self._get_opportunity_search_record = get_opportunity_search_record self.conformances = conformances + self.name = name self.openapi_endpoint_name = openapi_endpoint_name self.docs_endpoint_name = docs_endpoint_name self.product_ids: list[str] = [] @@ -104,49 +133,82 @@ def __init__( tags=["Orders"], ) - def get_root(self, request: Request) -> RootResponse: - return RootResponse( - id="STAPI API", - conformsTo=self.conformances, - links=[ - Link( - href=str(request.url_for(f"{self.name}:root")), - rel="self", - type=TYPE_JSON, - ), - Link( - href=str(request.url_for(f"{self.name}:conformance")), - rel="conformance", - type=TYPE_JSON, - ), - Link( - href=str(request.url_for(f"{self.name}:list-products")), - rel="products", - type=TYPE_JSON, - ), - Link( - href=str(request.url_for(f"{self.name}:list-orders")), - rel="orders", - type=TYPE_JSON, - ), + if ASYNC_OPPORTUNITIES in conformances: + self.add_api_route( + "/searches/opportunities", + self.get_opportunity_search_records, + methods=["GET"], + name=f"{self.name}:list-opportunity-search-records", + summary="List all Opportunity Search Records", + tags=["Opportunities"], + ) + + self.add_api_route( + "/searches/opportunities/{search_record_id}", + self.get_opportunity_search_record, + methods=["GET"], + name=f"{self.name}:get-opportunity-search-record", + summary="Get an Opportunity Search Record by ID", + tags=["Opportunities"], + ) + + def get_root(self: Self, request: Request) -> RootResponse: + links = [ + Link( + href=str(request.url_for(f"{self.name}:root")), + rel="self", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(f"{self.name}:conformance")), + rel="conformance", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(f"{self.name}:list-products")), + rel="products", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(f"{self.name}:list-orders")), + rel="orders", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(self.openapi_endpoint_name)), + rel="service-description", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(self.docs_endpoint_name)), + rel="service-docs", + type="text/html", + ), + ] + + if self.supports_async_opportunity_search: + links.insert( + -2, Link( - href=str(request.url_for(self.openapi_endpoint_name)), - rel="service-description", + href=str( + request.url_for(f"{self.name}:list-opportunity-search-records") + ), + rel="opportunity-search-records", type=TYPE_JSON, ), - Link( - href=str(request.url_for(self.docs_endpoint_name)), - rel="service-docs", - type="text/html", - ), - ], + ) + + return RootResponse( + id="STAPI API", + conformsTo=self.conformances, + links=links, ) - def get_conformance(self, request: Request) -> Conformance: + def get_conformance(self: Self) -> Conformance: return Conformance(conforms_to=self.conformances) def get_products( - self, request: Request, next: str | None = None, limit: int = 10 + self: Self, request: Request, next: str | None = None, limit: int = 10 ) -> ProductsCollection: start = 0 limit = min(limit, 100) @@ -178,7 +240,7 @@ def get_products( ) async def get_orders( - self, request: Request, next: str | None = None, limit: int = 10 + self: Self, request: Request, next: str | None = None, limit: int = 10 ) -> OrderCollection: links: list[Link] = [] match await self._get_orders(next, limit, request): @@ -271,7 +333,7 @@ def generate_order_statuses_href( ) -> URL: return request.url_for(f"{self.name}:list-order-statuses", order_id=order_id) - def add_order_links(self, order: Order, request: Request): + def add_order_links(self: Self, order: Order, request: Request): order.links.append( Link( href=str(self.generate_order_href(request, order.id)), @@ -312,3 +374,86 @@ def pagination_link(self, request: Request, pagination_token: str): rel="next", type=TYPE_JSON, ) + + async def get_opportunity_search_records( + self: Self, request: Request, next: str | None = None, limit: int = 10 + ) -> OpportunitySearchRecords: + links: list[Link] = [] + match await self._get_opportunity_search_records(request, next, limit): + case Success((records, Some(pagination_token))): + for record in records: + self.add_opportunity_search_record_self_link(record, request) + links.append(self.pagination_link(request, pagination_token)) + case Success((records, Nothing)): # noqa: F841 + for record in records: + self.add_opportunity_search_record_self_link(record, request) + case Failure(ValueError()): + raise NotFoundException(detail="Error finding pagination token") + case Failure(e): + logger.error( + "An error occurred while retrieving opportunity search records: %s", + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error finding Opportunity Search Records", + ) + case _: + raise AssertionError("Expected code to be unreachable") + return OpportunitySearchRecords(search_records=records, links=links) + + async def get_opportunity_search_record( + self: Self, search_record_id: str, request: Request + ) -> OpportunitySearchRecord: + """ + Get the Opportunity Search Record with `search_record_id`. + """ + match await self._get_opportunity_search_record(search_record_id, request): + case Success(Maybe(Some(search_record))): + self.add_opportunity_search_record_self_link(search_record, request) + return search_record + case Success(Maybe.empty): + raise NotFoundException("Opportunity Search Record not found") + case Failure(e): + logger.error( + "An error occurred while retrieving opportunity search record '%s': %s", + search_record_id, + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error finding Opportunity Search Record", + ) + case _: + raise AssertionError("Expected code to be unreachable") + + def generate_opportunity_search_record_href( + self: Self, request: Request, search_record_id: str + ) -> URL: + return request.url_for( + f"{self.name}:get-opportunity-search-record", + search_record_id=search_record_id, + ) + + def add_opportunity_search_record_self_link( + self: Self, opportunity_search_record: OpportunitySearchRecord, request: Request + ) -> None: + opportunity_search_record.links.append( + Link( + href=str( + self.generate_opportunity_search_record_href( + request, opportunity_search_record.id + ) + ), + rel="self", + type=TYPE_JSON, + ) + ) + + @property + def supports_async_opportunity_search(self: Self) -> bool: + return ( + ASYNC_OPPORTUNITIES in self.conformances + and hasattr(self, "_get_opportunity_search_records") + and hasattr(self, "_get_opportunity_search_record") + ) From 37bda4b358b78a1ea0142b08b74ef95be90e5471 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Tue, 4 Feb 2025 17:43:52 -0600 Subject: [PATCH 05/21] fix: revert back to a clunky, but functional, opportunity search endpoint --- src/stapi_fastapi/models/opportunity.py | 2 +- src/stapi_fastapi/routers/product_router.py | 63 +++++++++------------ 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/src/stapi_fastapi/models/opportunity.py b/src/stapi_fastapi/models/opportunity.py index 5061709..c3c7254 100644 --- a/src/stapi_fastapi/models/opportunity.py +++ b/src/stapi_fastapi/models/opportunity.py @@ -59,7 +59,7 @@ class OpportunitySearchStatus(BaseModel): class OpportunitySearchRecord(BaseModel): id: str product_id: str - request: OpportunityRequest + opportunity_request: OpportunityRequest status: OpportunitySearchStatus links: list[Link] = Field(default_factory=list) diff --git a/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py index ead25bd..117f7b5 100644 --- a/src/stapi_fastapi/routers/product_router.py +++ b/src/stapi_fastapi/routers/product_router.py @@ -14,6 +14,7 @@ Response, status, ) +from fastapi.responses import JSONResponse from geojson_pydantic.geometries import Geometry from returns.maybe import Maybe, Some from returns.result import Failure, Success @@ -48,7 +49,7 @@ def get_preference(prefer: str | None = Header(None)) -> str | None: detail=f"Invalid Prefer header value: {prefer}", ) - return prefer + return Prefer(prefer) class ProductRouter(APIRouter): @@ -129,7 +130,10 @@ async def _create_order( tags=["Products"], ) - if product.supports_opportunity_search: + if ( + product.supports_opportunity_search + or root_router.supports_async_opportunity_search + ): self.add_api_route( path="/opportunities", endpoint=self.search_opportunities, @@ -141,21 +145,17 @@ async def _create_order( Geometry, self.product.opportunity_properties, # type: ignore ], + responses={ + 201: { + "model": OpportunitySearchRecord, + "content": {TYPE_JSON: {}}, + } + }, summary="Search Opportunities for the product", tags=["Products"], ) if root_router.supports_async_opportunity_search: - self.add_api_route( - path="/opportunities", - endpoint=self.search_opportunities_async, - name=f"{self.root_router.name}:{self.product.id}:search-opportunities", - methods=["POST"], - status_code=status.HTTP_201_CREATED, - summary="Search Opportunities for the product", - tags=["Products"], - ) - self.add_api_route( path="/opportunities/{opportunity_collection_id}", endpoint=self.get_opportunity_collection, @@ -216,18 +216,19 @@ def get_product(self: Self, request: Request) -> Product: ], ) - async def search_opportunities( + async def search_opportunities( # noqa: C901 self: Self, search: OpportunityRequest, request: Request, - response: GeoJSONResponse, - prefer: str | None = Depends(get_preference), + response: Response, next: Annotated[str | None, Body()] = None, limit: Annotated[int, Body()] = 10, - ) -> OpportunityCollection: + prefer: str | None = Depends(get_preference), + ) -> OpportunityCollection | Response: """ Explore the opportunities available for a particular set of constraints """ + # synchronous opportunities search if ( not self.root_router.supports_async_opportunity_search or prefer is Prefer.wait @@ -272,20 +273,7 @@ async def search_opportunities( return OpportunityCollection(features=features, links=links) - raise AssertionError("Expected code to be unreachable") - - async def search_opportunities_async( - self: Self, - search: OpportunityRequest, - request: Request, - response: Response, - prefer: str | None = Depends(get_preference), - ) -> OpportunitySearchRecord: - """ - Initiate an asynchronous search for opportunities. - - TODO: Do I need a location header somewhere? - """ + # asynchronous opportunities search if ( prefer is None or prefer is Prefer.respond_async @@ -296,14 +284,19 @@ async def search_opportunities_async( self.root_router.add_opportunity_search_record_self_link( search_record, request ) - response.headers["Location"] = str( + headers = {} + headers["Location"] = str( self.root_router.generate_opportunity_search_record_href( request, search_record.id ) ) if prefer is not None: - response.headers["Preference-Applied"] = "respond-async" - return search_record + headers["Preference-Applied"] = "respond-async" + return JSONResponse( + status_code=201, + content=search_record.model_dump(mode="json"), + headers=headers, + ) case Failure(e) if isinstance(e, ConstraintsException): raise e case Failure(e): @@ -315,8 +308,8 @@ async def search_opportunities_async( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Error initiating an asynchronous opportunity search", ) - case x: - raise AssertionError(f"Expected code to be unreachable: {x}") + case y: + raise AssertionError(f"Expected code to be unreachable: {y}") raise AssertionError("Expected code to be unreachable") From f21a15211536f8b7ba78fff703c125e2e4dce265 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Wed, 5 Feb 2025 12:42:24 -0600 Subject: [PATCH 06/21] tests: add tests for async opportunity searching --- src/stapi_fastapi/backends/root_backend.py | 2 +- src/stapi_fastapi/routers/product_router.py | 245 +++++++++++--------- src/stapi_fastapi/routers/root_router.py | 2 +- tests/application.py | 17 +- tests/backends.py | 130 +++++++++-- tests/conftest.py | 76 +++++- tests/shared.py | 71 +++++- tests/test_opportunity.py | 63 +---- tests/test_opportunity_async.py | 140 +++++++++++ tests/test_product.py | 13 +- 10 files changed, 550 insertions(+), 209 deletions(-) create mode 100644 tests/test_opportunity_async.py diff --git a/src/stapi_fastapi/backends/root_backend.py b/src/stapi_fastapi/backends/root_backend.py index be8dc8e..3d5edfc 100644 --- a/src/stapi_fastapi/backends/root_backend.py +++ b/src/stapi_fastapi/backends/root_backend.py @@ -71,7 +71,7 @@ """ GetOpportunitySearchRecords = Callable[ - [Request, str | None, int], + [str | None, int, Request], Coroutine[Any, Any, ResultE[tuple[list[OpportunitySearchRecord], Maybe[str]]]], ] """ diff --git a/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py index 117f7b5..45a1970 100644 --- a/src/stapi_fastapi/routers/product_router.py +++ b/src/stapi_fastapi/routers/product_router.py @@ -166,153 +166,182 @@ async def _create_order( ) def get_product(self: Self, request: Request) -> Product: - return self.product.with_links( - links=[ - Link( - href=str( - request.url_for( - f"{self.root_router.name}:{self.product.id}:get-product", - ), + links = [ + Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:get-product", ), - rel="self", - type=TYPE_JSON, ), - Link( - href=str( - request.url_for( - f"{self.root_router.name}:{self.product.id}:get-constraints", - ), + rel="self", + type=TYPE_JSON, + ), + Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:get-constraints", ), - rel="constraints", - type=TYPE_JSON, ), - Link( - href=str( - request.url_for( - f"{self.root_router.name}:{self.product.id}:get-order-parameters", - ), + rel="constraints", + type=TYPE_JSON, + ), + Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:get-order-parameters", ), - rel="order-parameters", - type=TYPE_JSON, ), - Link( - href=str( - request.url_for( - f"{self.root_router.name}:{self.product.id}:search-opportunities", - ), + rel="order-parameters", + type=TYPE_JSON, + ), + Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:create-order", ), - rel="opportunities", - type=TYPE_JSON, ), + rel="create-order", + type=TYPE_JSON, + ), + ] + + if ( + self.product.supports_opportunity_search + or self.root_router.supports_async_opportunity_search + ): + links.append( Link( href=str( request.url_for( - f"{self.root_router.name}:{self.product.id}:create-order", + f"{self.root_router.name}:{self.product.id}:search-opportunities", ), ), - rel="create-order", + rel="opportunities", type=TYPE_JSON, ), - ], - ) + ) - async def search_opportunities( # noqa: C901 + return self.product.with_links(links=links) + + async def search_opportunities( self: Self, search: OpportunityRequest, request: Request, response: Response, next: Annotated[str | None, Body()] = None, limit: Annotated[int, Body()] = 10, - prefer: str | None = Depends(get_preference), + prefer: Prefer | None = Depends(get_preference), ) -> OpportunityCollection | Response: """ Explore the opportunities available for a particular set of constraints """ - # synchronous opportunities search - if ( - not self.root_router.supports_async_opportunity_search - or prefer is Prefer.wait + # sync + if not self.root_router.supports_async_opportunity_search or ( + prefer is Prefer.wait and self.product.supports_opportunity_search ): - links: list[Link] = [] - match await self.product._search_opportunities( - self, + return await self.search_opportunities_sync( search, + request, + response, + prefer, next, limit, - request, - ): - case Success((features, Some(pagination_token))): - links.append(self.order_link(request)) - body = { - "search": search.model_dump(mode="json"), - "next": pagination_token, - "limit": limit, - } - links.append(self.pagination_link(request, body)) - case Success((features, Nothing)): # noqa: F841 - links.append(self.order_link(request)) - case Failure(e) if isinstance(e, ConstraintsException): - raise e - case Failure(e): - logger.error( - "An error occurred while searching opportunities: %s", - traceback.format_exception(e), - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error searching opportunities", - ) - case x: - raise AssertionError(f"Expected code to be unreachable {x}") - - if ( - prefer is Prefer.wait - and self.root_router.supports_async_opportunity_search - ): - response.headers["Preference-Applied"] = "wait" - - return OpportunityCollection(features=features, links=links) + ) - # asynchronous opportunities search + # async if ( prefer is None or prefer is Prefer.respond_async or (prefer is Prefer.wait and not self.product.supports_opportunity_search) ): - match await self.product._search_opportunities_async(self, search, request): - case Success(search_record): - self.root_router.add_opportunity_search_record_self_link( - search_record, request - ) - headers = {} - headers["Location"] = str( - self.root_router.generate_opportunity_search_record_href( - request, search_record.id - ) - ) - if prefer is not None: - headers["Preference-Applied"] = "respond-async" - return JSONResponse( - status_code=201, - content=search_record.model_dump(mode="json"), - headers=headers, - ) - case Failure(e) if isinstance(e, ConstraintsException): - raise e - case Failure(e): - logger.error( - "An error occurred while initiating an asynchronous opportunity search: %s", - traceback.format_exception(e), - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error initiating an asynchronous opportunity search", - ) - case y: - raise AssertionError(f"Expected code to be unreachable: {y}") + return await self.search_opportunities_async(search, request, prefer) raise AssertionError("Expected code to be unreachable") + async def search_opportunities_sync( + self: Self, + search: OpportunityRequest, + request: Request, + response: Response, + prefer: Prefer | None, + next: Annotated[str | None, Body()] = None, + limit: Annotated[int, Body()] = 10, + ) -> OpportunityCollection: + links: list[Link] = [] + match await self.product._search_opportunities( + self, + search, + next, + limit, + request, + ): + case Success((features, Some(pagination_token))): + links.append(self.order_link(request)) + body = { + "search": search.model_dump(mode="json"), + "next": pagination_token, + "limit": limit, + } + links.append(self.pagination_link(request, body)) + case Success((features, Nothing)): # noqa: F841 + links.append(self.order_link(request)) + case Failure(e) if isinstance(e, ConstraintsException): + raise e + case Failure(e): + logger.error( + "An error occurred while searching opportunities: %s", + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error searching opportunities", + ) + case x: + raise AssertionError(f"Expected code to be unreachable {x}") + + if prefer is Prefer.wait and self.root_router.supports_async_opportunity_search: + response.headers["Preference-Applied"] = "wait" + + return OpportunityCollection(features=features, links=links) + + async def search_opportunities_async( + self: Self, + search: OpportunityRequest, + request: Request, + prefer: Prefer | None, + ) -> JSONResponse: + match await self.product._search_opportunities_async(self, search, request): + case Success(search_record): + self.root_router.add_opportunity_search_record_self_link( + search_record, request + ) + headers = {} + headers["Location"] = str( + self.root_router.generate_opportunity_search_record_href( + request, search_record.id + ) + ) + if prefer is not None: + headers["Preference-Applied"] = "respond-async" + return JSONResponse( + status_code=201, + content=search_record.model_dump(mode="json"), + headers=headers, + ) + case Failure(e) if isinstance(e, ConstraintsException): + raise e + case Failure(e): + logger.error( + "An error occurred while initiating an asynchronous opportunity search: %s", + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error initiating an asynchronous opportunity search", + ) + case x: + raise AssertionError(f"Expected code to be unreachable: {x}") + def get_product_constraints(self: Self) -> JsonSchemaModel: """ Return supported constraints of a specific product diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index 0f88f63..7325a94 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -379,7 +379,7 @@ async def get_opportunity_search_records( self: Self, request: Request, next: str | None = None, limit: int = 10 ) -> OpportunitySearchRecords: links: list[Link] = [] - match await self._get_opportunity_search_records(request, next, limit): + match await self._get_opportunity_search_records(next, limit, request): case Success((records, Some(pagination_token))): for record in records: self.add_opportunity_search_record_self_link(record, request) diff --git a/tests/application.py b/tests/application.py index b9442c7..2fcfdf6 100644 --- a/tests/application.py +++ b/tests/application.py @@ -8,14 +8,20 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from stapi_fastapi.models.conformance import CORE +from stapi_fastapi.models.conformance import ASYNC_OPPORTUNITIES, CORE, OPPORTUNITIES from stapi_fastapi.routers.root_router import RootRouter from tests.backends import ( + mock_get_opportunity_search_record, + mock_get_opportunity_search_records, mock_get_order, mock_get_order_statuses, mock_get_orders, ) -from tests.shared import InMemoryOrderDB, mock_product_test_spotlight +from tests.shared import ( + InMemoryOpportunityDB, + InMemoryOrderDB, + product_test_spotlight_sync_async_opportunity, +) @asynccontextmanager @@ -23,6 +29,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: try: yield { "_orders_db": InMemoryOrderDB(), + "_opportunities_db": InMemoryOpportunityDB(), } finally: pass @@ -32,8 +39,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: get_orders=mock_get_orders, get_order=mock_get_order, get_order_statuses=mock_get_order_statuses, - conformances=[CORE], + get_opportunity_search_records=mock_get_opportunity_search_records, + get_opportunity_search_record=mock_get_opportunity_search_record, + conformances=[CORE, OPPORTUNITIES, ASYNC_OPPORTUNITIES], ) -root_router.add_product(mock_product_test_spotlight) +root_router.add_product(product_test_spotlight_sync_async_opportunity) app: FastAPI = FastAPI(lifespan=lifespan) app.include_router(root_router, prefix="") diff --git a/tests/backends.py b/tests/backends.py index 7b17d92..a6ce23d 100644 --- a/tests/backends.py +++ b/tests/backends.py @@ -7,7 +7,11 @@ from stapi_fastapi.models.opportunity import ( Opportunity, + OpportunityCollection, OpportunityRequest, + OpportunitySearchRecord, + OpportunitySearchStatus, + OpportunitySearchStatusCode, ) from stapi_fastapi.models.order import ( Order, @@ -74,30 +78,6 @@ async def mock_get_order_statuses( return Failure(e) -async def mock_search_opportunities( - product_router: ProductRouter, - search: OpportunityRequest, - next: str | None, - limit: int, - request: Request, -) -> ResultE[tuple[list[Opportunity], Maybe[str]]]: - try: - start = 0 - limit = min(limit, 100) - if next: - start = int(next) - end = start + limit - opportunities = [ - o.model_copy(update=search.model_dump()) - 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)) - except Exception as e: - return Failure(e) - - async def mock_create_order( product_router: ProductRouter, payload: OrderPayload, request: Request ) -> ResultE[Order]: @@ -135,3 +115,105 @@ async def mock_create_order( return Success(order) except Exception as e: return Failure(e) + + +async def mock_search_opportunities( + product_router: ProductRouter, + search: OpportunityRequest, + next: str | None, + limit: int, + request: Request, +) -> ResultE[tuple[list[Opportunity], Maybe[str]]]: + try: + start = 0 + limit = min(limit, 100) + if next: + start = int(next) + end = start + limit + opportunities = [ + o.model_copy(update=search.model_dump()) + 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)) + except Exception as e: + return Failure(e) + + +async def mock_search_opportunities_async( + product_router: ProductRouter, + search: OpportunityRequest, + request: Request, +) -> ResultE[OpportunitySearchRecord]: + try: + status = OpportunitySearchStatus( + timestamp=datetime.now(timezone.utc), + status_code=OpportunitySearchStatusCode.received, + ) + search_record = OpportunitySearchRecord( + id=str(uuid4()), + product_id=product_router.product.id, + opportunity_request=search, + status=status, + links=[], + ) + request.state._opportunities_db._search_records[search_record.id] = ( + search_record + ) + request.state._opportunities_db._search_record_statuses[ + search_record.id + ].insert(0, status) + return Success(search_record) + except Exception as e: + return Failure(e) + + +async def mock_get_opportunity_collection( + product_router: ProductRouter, opportunity_collection_id: str, request: Request +) -> ResultE[Maybe[OpportunityCollection]]: + try: + return Success( + Maybe.from_optional( + request.state._opportunities_db._collections.get( + opportunity_collection_id + ) + ) + ) + except Exception as e: + return Failure(e) + + +async def mock_get_opportunity_search_records( + next: str | None, + limit: int, + request: Request, +) -> ResultE[tuple[list[OpportunitySearchRecord], Maybe[str]]]: + try: + start = 0 + limit = min(limit, 100) + search_records = list(request.state._opportunities_db._search_records.values()) + + if next: + start = int(next) + end = start + limit + page = search_records[start:end] + + if end > 0 and end < len(search_records): + return Success((page, Some(str(end)))) + return Success((page, Nothing)) + except Exception as e: + return Failure(e) + + +async def mock_get_opportunity_search_record( + search_record_id: str, request: Request +) -> ResultE[Maybe[OpportunitySearchRecord]]: + try: + return Success( + Maybe.from_optional( + request.state._opportunities_db._search_records.get(search_record_id) + ) + ) + except Exception as e: + return Failure(e) diff --git a/tests/conftest.py b/tests/conftest.py index c060495..02559ec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ from collections.abc import AsyncIterator, Iterator from contextlib import asynccontextmanager +from datetime import UTC, datetime, timedelta from typing import Any, Callable from urllib.parse import urljoin @@ -7,6 +8,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient +from stapi_fastapi.models.conformance import ASYNC_OPPORTUNITIES, CORE, OPPORTUNITIES from stapi_fastapi.models.opportunity import ( Opportunity, ) @@ -16,17 +18,21 @@ from stapi_fastapi.routers.root_router import RootRouter from .backends import ( + mock_get_opportunity_search_record, + 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, - mock_product_test_satellite_provider, - mock_product_test_spotlight, + product_test_satellite_provider_sync_opportunity, + product_test_spotlight_sync_opportunity, ) +from .test_datetime_interval import rfc3339_strftime @pytest.fixture(scope="session") @@ -36,7 +42,10 @@ def base_url() -> Iterator[str]: @pytest.fixture def mock_products() -> list[Product]: - return [mock_product_test_spotlight, mock_product_test_satellite_provider] + return [ + product_test_spotlight_sync_opportunity, + product_test_satellite_provider_sync_opportunity, + ] @pytest.fixture @@ -64,9 +73,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: get_orders=mock_get_orders, get_order=mock_get_order, get_order_statuses=mock_get_order_statuses, + conformances=[CORE], ) + for mock_product in mock_products: root_router.add_product(mock_product) + app = FastAPI(lifespan=lifespan) app.include_router(root_router, prefix="") @@ -75,13 +87,35 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: @pytest.fixture -def empty_stapi_client(base_url: str) -> Iterator[TestClient]: +def stapi_client_async_opportunity( + mock_products: list[Product], + base_url: str, + mock_opportunities: list[Opportunity], +) -> Iterator[TestClient]: + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: + try: + yield { + "_orders_db": InMemoryOrderDB(), + "_opportunities_db": InMemoryOpportunityDB(), + "_opportunities": mock_opportunities, + } + finally: + pass + 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=[CORE, OPPORTUNITIES, ASYNC_OPPORTUNITIES], ) - app = FastAPI() + + for mock_product in mock_products: + root_router.add_product(mock_product) + + app = FastAPI(lifespan=lifespan) app.include_router(root_router, prefix="") with TestClient(app, base_url=f"{base_url}") as client: @@ -114,3 +148,35 @@ def _assert_link( assert link["href"] == url_for(path) return _assert_link + + +@pytest.fixture +def limit() -> int: + return 10 + + +@pytest.fixture +def opportunity_search(limit) -> dict[str, Any]: + now = datetime.now(UTC) + end = now + timedelta(days=5) + format = "%Y-%m-%dT%H:%M:%S.%f%z" + start_string = rfc3339_strftime(now, format) + end_string = rfc3339_strftime(end, format) + + return { + "search": { + "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/tests/shared.py b/tests/shared.py index 45bff0d..5ec5903 100644 --- a/tests/shared.py +++ b/tests/shared.py @@ -13,7 +13,9 @@ from stapi_fastapi.models.opportunity import ( Opportunity, + OpportunityCollection, OpportunityProperties, + OpportunitySearchRecord, ) from stapi_fastapi.models.order import ( Order, @@ -28,7 +30,9 @@ from .backends import ( mock_create_order, + mock_get_opportunity_collection, mock_search_opportunities, + mock_search_opportunities_async, ) type link_dict = dict[str, Any] @@ -44,6 +48,13 @@ def __init__(self) -> None: self._statuses: dict[str, list[OrderStatus]] = defaultdict(list) +class InMemoryOpportunityDB: + def __init__(self) -> None: + self._search_records: dict[str, OpportunitySearchRecord] = {} + self._search_record_statuses: dict[str, list[OrderStatus]] = defaultdict(list) + self._collections: dict[str, OpportunityCollection] = {} + + class MyProductConstraints(BaseModel): off_nadir: int @@ -76,7 +87,59 @@ class MyOrderParameters(OrderParameters): url="https://test-provider.example.com", # Must be a valid URL ) -mock_product_test_spotlight = Product( +product_test_spotlight = Product( + id="test-spotlight", + title="Test Spotlight Product", + description="Test product for test spotlight", + license="CC-BY-4.0", + keywords=["test", "satellite"], + providers=[provider], + links=[], + create_order=mock_create_order, + search_opportunities=None, + search_opportunities_async=None, + get_opportunity_collection=None, + constraints=MyProductConstraints, + opportunity_properties=MyOpportunityProperties, + order_parameters=MyOrderParameters, +) + +product_test_spotlight_sync_opportunity = Product( + id="test-spotlight", + title="Test Spotlight Product", + description="Test product for test spotlight", + license="CC-BY-4.0", + keywords=["test", "satellite"], + providers=[provider], + links=[], + create_order=mock_create_order, + search_opportunities=mock_search_opportunities, + search_opportunities_async=None, + get_opportunity_collection=None, + constraints=MyProductConstraints, + opportunity_properties=MyOpportunityProperties, + order_parameters=MyOrderParameters, +) + + +product_test_spotlight_async_opportunity = Product( + id="test-spotlight", + title="Test Spotlight Product", + description="Test product for test spotlight", + license="CC-BY-4.0", + keywords=["test", "satellite"], + providers=[provider], + links=[], + create_order=mock_create_order, + search_opportunities=None, + search_opportunities_async=mock_search_opportunities_async, + get_opportunity_collection=mock_get_opportunity_collection, + constraints=MyProductConstraints, + opportunity_properties=MyOpportunityProperties, + order_parameters=MyOrderParameters, +) + +product_test_spotlight_sync_async_opportunity = Product( id="test-spotlight", title="Test Spotlight Product", description="Test product for test spotlight", @@ -86,12 +149,14 @@ class MyOrderParameters(OrderParameters): links=[], create_order=mock_create_order, search_opportunities=mock_search_opportunities, + search_opportunities_async=mock_search_opportunities_async, + get_opportunity_collection=mock_get_opportunity_collection, constraints=MyProductConstraints, opportunity_properties=MyOpportunityProperties, order_parameters=MyOrderParameters, ) -mock_product_test_satellite_provider = Product( +product_test_satellite_provider_sync_opportunity = Product( id="test-satellite-provider", title="Satellite Product", description="A product by a satellite provider", @@ -101,6 +166,8 @@ class MyOrderParameters(OrderParameters): links=[], create_order=mock_create_order, search_opportunities=mock_search_opportunities, + search_opportunities_async=None, + get_opportunity_collection=None, constraints=MyProductConstraints, opportunity_properties=MyOpportunityProperties, order_parameters=MyOrderParameters, diff --git a/tests/test_opportunity.py b/tests/test_opportunity.py index 389d47d..fce63cc 100644 --- a/tests/test_opportunity.py +++ b/tests/test_opportunity.py @@ -1,5 +1,3 @@ -from datetime import UTC, datetime, timedelta - import pytest from fastapi.testclient import TestClient @@ -8,42 +6,15 @@ ) from .shared import create_mock_opportunity, pagination_tester -from .test_datetime_interval import rfc3339_strftime -@pytest.mark.parametrize("product_id", ["test-spotlight"]) def test_search_opportunities_response( - product_id: str, - stapi_client: TestClient, - assert_link, + stapi_client: TestClient, assert_link, opportunity_search ) -> None: - now = datetime.now(UTC) - end = now + timedelta(days=5) - format = "%Y-%m-%dT%H:%M:%S.%f%z" - start_string = rfc3339_strftime(now, format) - end_string = rfc3339_strftime(end, format) - - request_payload = { - "search": { - "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": 10, - } - + product_id = "test-spotlight" url = f"/products/{product_id}/opportunities" - response = stapi_client.post(url, json=request_payload) + response = stapi_client.post(url, json=opportunity_search) assert response.status_code == 200, f"Failed for product: {product_id}" body = response.json() @@ -63,6 +34,7 @@ def test_search_opportunities_response( def test_search_opportunities_pagination( limit: int, stapi_client: TestClient, + opportunity_search, ) -> None: mock_pagination_opportunities = [create_mock_opportunity() for __ in range(3)] stapi_client.app_state["_opportunities"] = mock_pagination_opportunities @@ -73,31 +45,6 @@ def test_search_opportunities_pagination( x.model_dump(mode="json") for x in mock_pagination_opportunities ] - now = datetime.now(UTC) - start = now - end = start + timedelta(days=5) - format = "%Y-%m-%dT%H:%M:%S.%f%z" - start_string = rfc3339_strftime(start, format) - end_string = rfc3339_strftime(end, format) - - request_payload = { - "search": { - "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, - } - pagination_tester( stapi_client=stapi_client, endpoint=f"/products/{product_id}/opportunities", @@ -105,5 +52,5 @@ def test_search_opportunities_pagination( limit=limit, target="features", expected_returns=expected_returns, - body=request_payload, + body=opportunity_search, ) diff --git a/tests/test_opportunity_async.py b/tests/test_opportunity_async.py new file mode 100644 index 0000000..7bc952b --- /dev/null +++ b/tests/test_opportunity_async.py @@ -0,0 +1,140 @@ +import pytest +from fastapi.testclient import TestClient + +from stapi_fastapi.models.opportunity import ( + OpportunityCollection, + OpportunitySearchRecord, +) + +from .shared import ( + product_test_spotlight, + product_test_spotlight_async_opportunity, + product_test_spotlight_sync_async_opportunity, +) + + +@pytest.mark.parametrize("mock_products", [[product_test_spotlight]]) +def test_no_opportunity_search( + stapi_client: TestClient, assert_link, mock_products +) -> None: + # TODO: Add checks for root async links + product_id = "test-spotlight" + response = stapi_client.get(f"/products/{product_id}") + + body = response.json() + url = "GET /products" + + with pytest.raises(AssertionError, match=".*should exist"): + assert_link(url, body, "opportunities", f"/products/{product_id}/opportunities") + + +# handled in test_opportunity.py +def test_sync_search() -> None: + pass + + +@pytest.mark.parametrize("mock_products", [[product_test_spotlight_async_opportunity]]) +def test_async_search_response( + stapi_client_async_opportunity: TestClient, + assert_link, + opportunity_search, + mock_products, +) -> None: + product_id = "test-spotlight" + url = f"/products/{product_id}/opportunities" + + response = stapi_client_async_opportunity.post(url, json=opportunity_search) + + assert response.status_code == 201, f"Failed for product: {product_id}" + body = response.json() + + try: + _ = OpportunitySearchRecord(**body) + except Exception as _: + pytest.fail("response is not an opportunity search record") + + assert_link( + f"GET /searches/opportunities/{body['id']}", + body, + "self", + f"/searches/opportunities/{body['id']}", + media_type="application/json", + ) + + +@pytest.mark.parametrize( + "mock_products", [[product_test_spotlight_sync_async_opportunity]] +) +def test_async_search_is_default( + stapi_client_async_opportunity: TestClient, + assert_link, + mock_products, + opportunity_search, +) -> None: + product_id = "test-spotlight" + url = f"/products/{product_id}/opportunities" + + response = stapi_client_async_opportunity.post(url, json=opportunity_search) + + assert response.status_code == 201, f"Failed for product: {product_id}" + body = response.json() + + try: + _ = OpportunitySearchRecord(**body) + except Exception as _: + pytest.fail("response is not an opportunity search record") + + +@pytest.mark.parametrize( + "mock_products", [[product_test_spotlight_sync_async_opportunity]] +) +def test_prefer_header( + stapi_client_async_opportunity: TestClient, + assert_link, + mock_products, + opportunity_search, +) -> None: + product_id = "test-spotlight" + url = f"/products/{product_id}/opportunities" + + # prefer = "wait" + response = stapi_client_async_opportunity.post( + url, json=opportunity_search, headers={"Prefer": "wait"} + ) + + assert response.status_code == 200, f"Failed for product: {product_id}" + assert response.headers["Preference-Applied"] == "wait" + body = response.json() + + try: + OpportunityCollection(**body) + except Exception as _: + pytest.fail("response is not an opportunity collection") + + # prefer = "respond-async" + response = stapi_client_async_opportunity.post( + url, json=opportunity_search, headers={"Prefer": "respond-async"} + ) + + assert response.status_code == 201, f"Failed for product: {product_id}" + assert response.headers["Preference-Applied"] == "respond-async" + body = response.json() + + try: + OpportunitySearchRecord(**body) + except Exception as _: + pytest.fail("response is not an opportunity search record") + + +# test than after an async request we can get the search record by id and that it exists +# in the list returned from the searches/opportunities endpoint. + + +# test that we can get the completed opportunity collection from the +# /products/{product_id}/opportunities//{oppportunity_collection_id} endpoint + + +# will need some pagination testing in here + + +# there's a location header to check for diff --git a/tests/test_product.py b/tests/test_product.py index efca972..d0e770d 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -97,13 +97,13 @@ def test_get_products_pagination( "type": "application/json", }, { - "href": f"http://stapiserver/products/{product_id}/opportunities", - "rel": "opportunities", + "href": f"http://stapiserver/products/{product_id}/orders", + "rel": "create-order", "type": "application/json", }, { - "href": f"http://stapiserver/products/{product_id}/orders", - "rel": "create-order", + "href": f"http://stapiserver/products/{product_id}/opportunities", + "rel": "opportunities", "type": "application/json", }, ] @@ -124,8 +124,9 @@ def test_token_not_found(stapi_client: TestClient) -> None: assert res.status_code == status.HTTP_404_NOT_FOUND -def test_no_products(empty_stapi_client: TestClient): - res = empty_stapi_client.get("/products") +@pytest.mark.parametrize("mock_products", [[]]) +def test_no_products(stapi_client: TestClient): + res = stapi_client.get("/products") body = res.json() print("hold") assert res.status_code == status.HTTP_200_OK From 3db2a60351c54d9b088cb667b11c0d25cc81517e Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Thu, 6 Feb 2025 10:12:28 -0600 Subject: [PATCH 07/21] tests: add more async tests and fix a few bugs --- src/stapi_fastapi/models/opportunity.py | 2 +- src/stapi_fastapi/routers/product_router.py | 4 +- src/stapi_fastapi/routers/root_router.py | 2 +- tests/backends.py | 17 +- tests/shared.py | 19 +- tests/test_opportunity_async.py | 245 ++++++++++++++++---- 6 files changed, 232 insertions(+), 57 deletions(-) diff --git a/src/stapi_fastapi/models/opportunity.py b/src/stapi_fastapi/models/opportunity.py index c3c7254..c8692bf 100644 --- a/src/stapi_fastapi/models/opportunity.py +++ b/src/stapi_fastapi/models/opportunity.py @@ -65,7 +65,7 @@ class OpportunitySearchRecord(BaseModel): class OpportunitySearchRecords(BaseModel): - records: list[OpportunitySearchRecord] + search_records: list[OpportunitySearchRecord] links: list[Link] = Field(default_factory=list) diff --git a/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py index 45a1970..cbdddc5 100644 --- a/src/stapi_fastapi/routers/product_router.py +++ b/src/stapi_fastapi/routers/product_router.py @@ -429,7 +429,9 @@ async def get_opportunity_collection( type=TYPE_JSON, ), ) - return GeoJSONResponse(content=opportunity_collection.model_dump_json()) + return GeoJSONResponse( + content=opportunity_collection.model_dump(mode="json") + ) case Success(Maybe.empty): raise NotFoundException("Opportunity Collection not found") case Failure(e): diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index 7325a94..52f5210 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -409,7 +409,7 @@ async def get_opportunity_search_record( Get the Opportunity Search Record with `search_record_id`. """ match await self._get_opportunity_search_record(search_record_id, request): - case Success(Maybe(Some(search_record))): + case Success(Some(search_record)): self.add_opportunity_search_record_self_link(search_record, request) return search_record case Success(Maybe.empty): diff --git a/tests/backends.py b/tests/backends.py index a6ce23d..6b068d9 100644 --- a/tests/backends.py +++ b/tests/backends.py @@ -147,7 +147,7 @@ async def mock_search_opportunities_async( request: Request, ) -> ResultE[OpportunitySearchRecord]: try: - status = OpportunitySearchStatus( + received_status = OpportunitySearchStatus( timestamp=datetime.now(timezone.utc), status_code=OpportunitySearchStatusCode.received, ) @@ -155,15 +155,10 @@ async def mock_search_opportunities_async( id=str(uuid4()), product_id=product_router.product.id, opportunity_request=search, - status=status, + status=received_status, links=[], ) - request.state._opportunities_db._search_records[search_record.id] = ( - search_record - ) - request.state._opportunities_db._search_record_statuses[ - search_record.id - ].insert(0, status) + request.state._opportunities_db.put_search_record(search_record) return Success(search_record) except Exception as e: return Failure(e) @@ -175,7 +170,7 @@ async def mock_get_opportunity_collection( try: return Success( Maybe.from_optional( - request.state._opportunities_db._collections.get( + request.state._opportunities_db.get_opportunity_collection( opportunity_collection_id ) ) @@ -192,7 +187,7 @@ async def mock_get_opportunity_search_records( try: start = 0 limit = min(limit, 100) - search_records = list(request.state._opportunities_db._search_records.values()) + search_records = request.state._opportunities_db.get_search_records() if next: start = int(next) @@ -212,7 +207,7 @@ async def mock_get_opportunity_search_record( try: return Success( Maybe.from_optional( - request.state._opportunities_db._search_records.get(search_record_id) + request.state._opportunities_db.get_search_record(search_record_id) ) ) except Exception as e: diff --git a/tests/shared.py b/tests/shared.py index 5ec5903..143d81a 100644 --- a/tests/shared.py +++ b/tests/shared.py @@ -1,4 +1,5 @@ from collections import defaultdict +from copy import deepcopy from datetime import datetime, timedelta, timezone from typing import Any, Literal, Self from uuid import uuid4 @@ -51,9 +52,25 @@ def __init__(self) -> None: class InMemoryOpportunityDB: def __init__(self) -> None: self._search_records: dict[str, OpportunitySearchRecord] = {} - self._search_record_statuses: dict[str, list[OrderStatus]] = defaultdict(list) self._collections: dict[str, OpportunityCollection] = {} + def get_search_record(self, search_id: str) -> OpportunitySearchRecord | None: + return deepcopy(self._search_records.get(search_id)) + + def get_search_records(self) -> list[OpportunitySearchRecord]: + return deepcopy(list(self._search_records.values())) + + def put_search_record(self, search_record: OpportunitySearchRecord) -> None: + self._search_records[search_record.id] = deepcopy(search_record) + + def get_opportunity_collection(self, collection_id) -> OpportunityCollection | None: + return deepcopy(self._collections.get(collection_id)) + + def put_opportunity_collection(self, collection: OpportunityCollection) -> None: + if collection.id is None: + raise ValueError("collection must have an id") + self._collections[collection.id] = deepcopy(collection) + class MyProductConstraints(BaseModel): off_nadir: int diff --git a/tests/test_opportunity_async.py b/tests/test_opportunity_async.py index 7bc952b..8d76fe7 100644 --- a/tests/test_opportunity_async.py +++ b/tests/test_opportunity_async.py @@ -1,65 +1,111 @@ +from datetime import datetime, timezone +from typing import Any, Callable +from uuid import uuid4 + import pytest from fastapi.testclient import TestClient from stapi_fastapi.models.opportunity import ( OpportunityCollection, OpportunitySearchRecord, + OpportunitySearchStatus, + OpportunitySearchStatusCode, ) +from stapi_fastapi.models.product import Product +from stapi_fastapi.models.shared import Link from .shared import ( + create_mock_opportunity, + find_link, product_test_spotlight, product_test_spotlight_async_opportunity, product_test_spotlight_sync_async_opportunity, + product_test_spotlight_sync_opportunity, ) @pytest.mark.parametrize("mock_products", [[product_test_spotlight]]) -def test_no_opportunity_search( - stapi_client: TestClient, assert_link, mock_products +def test_no_opportunity_search_advertised( + stapi_client: TestClient, mock_products: list[Product] ) -> None: - # TODO: Add checks for root async links product_id = "test-spotlight" - response = stapi_client.get(f"/products/{product_id}") - body = response.json() - url = "GET /products" + # the `/products/{productId}/opportunities link should not be advertised on the product + product_response = stapi_client.get(f"/products/{product_id}") + product_body = product_response.json() + assert find_link(product_body["links"], "opportunities") is 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 + + +@pytest.mark.parametrize("mock_products", [[product_test_spotlight_sync_opportunity]]) +def test_only_sync_search_advertised( + stapi_client: TestClient, mock_products: list[Product] +) -> None: + product_id = "test-spotlight" + + # the `/products/{productId}/opportunities link should be advertised on the product + product_response = stapi_client.get(f"/products/{product_id}") + product_body = product_response.json() + assert find_link(product_body["links"], "opportunities") + + # 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 + + +# test async search offered +@pytest.mark.parametrize( + "mock_products", + [ + [product_test_spotlight_async_opportunity], + [product_test_spotlight_sync_async_opportunity], + ], +) +def test_async_search_advertised( + stapi_client_async_opportunity: TestClient, mock_products: list[Product] +) -> None: + product_id = "test-spotlight" - with pytest.raises(AssertionError, match=".*should exist"): - assert_link(url, body, "opportunities", f"/products/{product_id}/opportunities") + # the `/products/{productId}/opportunities link should be advertised on the product + product_response = stapi_client_async_opportunity.get(f"/products/{product_id}") + product_body = product_response.json() + assert find_link(product_body["links"], "opportunities") + # 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") -# handled in test_opportunity.py -def test_sync_search() -> None: + +def test_sync_search_response() -> None: + # handled in test_opportunity.py pass @pytest.mark.parametrize("mock_products", [[product_test_spotlight_async_opportunity]]) def test_async_search_response( stapi_client_async_opportunity: TestClient, - assert_link, - opportunity_search, - mock_products, + opportunity_search: dict[str, Any], + mock_products: list[Product], ) -> None: product_id = "test-spotlight" url = f"/products/{product_id}/opportunities" response = stapi_client_async_opportunity.post(url, json=opportunity_search) + assert response.status_code == 201 - assert response.status_code == 201, f"Failed for product: {product_id}" body = response.json() - try: _ = OpportunitySearchRecord(**body) except Exception as _: pytest.fail("response is not an opportunity search record") - assert_link( - f"GET /searches/opportunities/{body['id']}", - body, - "self", - f"/searches/opportunities/{body['id']}", - media_type="application/json", - ) + assert find_link(body["links"], "self") @pytest.mark.parametrize( @@ -67,18 +113,16 @@ def test_async_search_response( ) def test_async_search_is_default( stapi_client_async_opportunity: TestClient, - assert_link, - mock_products, - opportunity_search, + opportunity_search: dict[str, Any], + mock_products: list[Product], ) -> None: product_id = "test-spotlight" url = f"/products/{product_id}/opportunities" response = stapi_client_async_opportunity.post(url, json=opportunity_search) + assert response.status_code == 201 - assert response.status_code == 201, f"Failed for product: {product_id}" body = response.json() - try: _ = OpportunitySearchRecord(**body) except Exception as _: @@ -90,9 +134,8 @@ def test_async_search_is_default( ) def test_prefer_header( stapi_client_async_opportunity: TestClient, - assert_link, - mock_products, - opportunity_search, + opportunity_search: dict[str, Any], + mock_products: list[Product], ) -> None: product_id = "test-spotlight" url = f"/products/{product_id}/opportunities" @@ -101,11 +144,10 @@ def test_prefer_header( response = stapi_client_async_opportunity.post( url, json=opportunity_search, headers={"Prefer": "wait"} ) - - assert response.status_code == 200, f"Failed for product: {product_id}" + assert response.status_code == 200 assert response.headers["Preference-Applied"] == "wait" - body = response.json() + body = response.json() try: OpportunityCollection(**body) except Exception as _: @@ -115,26 +157,145 @@ def test_prefer_header( response = stapi_client_async_opportunity.post( url, json=opportunity_search, headers={"Prefer": "respond-async"} ) - - assert response.status_code == 201, f"Failed for product: {product_id}" + assert response.status_code == 201 assert response.headers["Preference-Applied"] == "respond-async" - body = response.json() + body = response.json() try: OpportunitySearchRecord(**body) except Exception as _: pytest.fail("response is not an opportunity search record") -# test than after an async request we can get the search record by id and that it exists -# in the list returned from the searches/opportunities endpoint. +@pytest.mark.parametrize("mock_products", [[product_test_spotlight_async_opportunity]]) +def test_async_search_record_retrieval( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], + mock_products: list[Product], +) -> None: + # post an async search + product_id = "test-spotlight" + url = f"/products/{product_id}/opportunities" + search_response = stapi_client_async_opportunity.post(url, json=opportunity_search) + assert search_response.status_code == 201 + search_response_body = search_response.json() + + # get the search record by id and verify it matches the original response + search_record_id = search_response_body["id"] + record_response = stapi_client_async_opportunity.get( + f"/searches/opportunities/{search_record_id}" + ) + assert record_response.status_code == 200 + record_response_body = record_response.json() + assert record_response_body == search_response_body + + # verify the search record is in the list of all search records + 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"] + ] + + +@pytest.mark.parametrize("mock_products", [[product_test_spotlight_async_opportunity]]) +def test_async_opportunity_search_to_completion( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], + mock_products: list[Product], + url_for: Callable[[str], str], +) -> None: + # Post a request for an async search + product_id = "test-spotlight" + url = f"/products/{product_id}/opportunities" + search_response = stapi_client_async_opportunity.post(url, json=opportunity_search) + assert search_response.status_code == 201 + search_record = OpportunitySearchRecord(**search_response.json()) + + # Simulate the search being completed by some external process: + # - an OpportunityCollection is created and stored in the database + collection = OpportunityCollection( + id=str(uuid4()), + features=[create_mock_opportunity()], + ) + collection.links.append( + Link( + rel="create-order", + href=url_for(f"/products/{product_id}/orders"), + body=search_record.opportunity_request.model_dump(), + ) + ) + collection.links.append( + Link( + rel="search-record", + href=url_for(f"/searches/opportunities/{search_record.id}"), + ) + ) + + stapi_client_async_opportunity.app_state[ + "_opportunities_db" + ].put_opportunity_collection(collection) + # - the OpportunitySearchRecord links and status are updated in the database + search_record.links.append( + Link( + rel="opportunities", + href=url_for(f"/products/{product_id}/opportunities/{collection.id}"), + ) + ) + search_record.status = OpportunitySearchStatus( + timestamp=datetime.now(timezone.utc), + status_code=OpportunitySearchStatusCode.completed, + ) + + stapi_client_async_opportunity.app_state["_opportunities_db"].put_search_record( + search_record + ) + + # Verify we can retrieve the OpportunitySearchRecord by its id and its status is + # `completed` + url = f"/searches/opportunities/{search_record.id}" + retrieved_search_response = stapi_client_async_opportunity.get(url) + assert retrieved_search_response.status_code == 200 + retrieved_search_record = OpportunitySearchRecord( + **retrieved_search_response.json() + ) + assert ( + retrieved_search_record.status.status_code + == OpportunitySearchStatusCode.completed + ) + + # Verify we can retrieve the OpportunityCollection from the + # OpportunitySearchRecord's `opportunities` link, and the retrieved + # OpportunityCollection contains an order link and a link pointing back to the + # OpportunitySearchRecord + opportunities_link = next( + x for x in retrieved_search_record.links if x.rel == "opportunities" + ) + url = str(opportunities_link.href) + retrieved_collection_response = stapi_client_async_opportunity.get(url) + assert retrieved_collection_response.status_code == 200 + retrieved_collection = OpportunityCollection(**retrieved_collection_response.json()) + assert any(x for x in retrieved_collection.links if x.rel == "create-order") + assert any(x for x in retrieved_collection.links if x.rel == "search-record") -# test that we can get the completed opportunity collection from the -# /products/{product_id}/opportunities//{oppportunity_collection_id} endpoint +@pytest.mark.parametrize("mock_products", [[product_test_spotlight_async_opportunity]]) +def test_new_search_location_header_matches_self_link( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], + mock_products: list[Product], +) -> None: + product_id = "test-spotlight" + url = f"/products/{product_id}/opportunities" + search_response = stapi_client_async_opportunity.post(url, json=opportunity_search) + assert search_response.status_code == 201 -# will need some pagination testing in here + search_record = search_response.json() + link = find_link(search_record["links"], "self") + assert link + assert search_response.headers["Location"] == str(link["href"]) -# there's a location header to check for +# Pagination test for the OpportunitySearchRecrods returned from the /searches/opportunities +# endpoint on the root router From b775a1e44542a8872d8e66b0c667a55eb328417a Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Thu, 6 Feb 2025 15:25:57 -0600 Subject: [PATCH 08/21] tests: add pagination tests for async search, fix a few bugs --- src/stapi_fastapi/routers/product_router.py | 6 +- src/stapi_fastapi/routers/root_router.py | 36 +++++---- tests/test_opportunity_async.py | 83 ++++++++++++++++++++- 3 files changed, 104 insertions(+), 21 deletions(-) diff --git a/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py index b42e76b..02f02a4 100644 --- a/src/stapi_fastapi/routers/product_router.py +++ b/src/stapi_fastapi/routers/product_router.py @@ -300,8 +300,10 @@ async def search_opportunities_async( ) -> JSONResponse: match await self.product._search_opportunities_async(self, search, request): case Success(search_record): - self.root_router.add_opportunity_search_record_self_link( - search_record, request + search_record.links.append( + self.root_router.opportunity_search_record_self_link( + search_record, request + ) ) headers = {} headers["Location"] = str( diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index 4c8604e..abab3b9 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -376,11 +376,15 @@ async def get_opportunity_search_records( match await self._get_opportunity_search_records(next, limit, request): case Success((records, Some(pagination_token))): for record in records: - self.add_opportunity_search_record_self_link(record, request) - links.append(self.pagination_link(request, pagination_token)) + record.links.append( + self.opportunity_search_record_self_link(record, request) + ) + links.append(self.pagination_link(request, pagination_token, limit)) case Success((records, Nothing)): # noqa: F841 for record in records: - self.add_opportunity_search_record_self_link(record, request) + record.links.append( + self.opportunity_search_record_self_link(record, request) + ) case Failure(ValueError()): raise NotFoundException(detail="Error finding pagination token") case Failure(e): @@ -404,7 +408,9 @@ async def get_opportunity_search_record( """ match await self._get_opportunity_search_record(search_record_id, request): case Success(Some(search_record)): - self.add_opportunity_search_record_self_link(search_record, request) + search_record.links.append( + self.opportunity_search_record_self_link(search_record, request) + ) return search_record case Success(Maybe.empty): raise NotFoundException("Opportunity Search Record not found") @@ -429,19 +435,17 @@ def generate_opportunity_search_record_href( search_record_id=search_record_id, ) - def add_opportunity_search_record_self_link( + def opportunity_search_record_self_link( self: Self, opportunity_search_record: OpportunitySearchRecord, request: Request - ) -> None: - opportunity_search_record.links.append( - Link( - href=str( - self.generate_opportunity_search_record_href( - request, opportunity_search_record.id - ) - ), - rel="self", - type=TYPE_JSON, - ) + ) -> Link: + return Link( + href=str( + self.generate_opportunity_search_record_href( + request, opportunity_search_record.id + ) + ), + rel="self", + type=TYPE_JSON, ) @property diff --git a/tests/test_opportunity_async.py b/tests/test_opportunity_async.py index 8d76fe7..4c252b9 100644 --- a/tests/test_opportunity_async.py +++ b/tests/test_opportunity_async.py @@ -1,8 +1,9 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime, timedelta, timezone from typing import Any, Callable from uuid import uuid4 import pytest +from fastapi import status from fastapi.testclient import TestClient from stapi_fastapi.models.opportunity import ( @@ -17,11 +18,13 @@ from .shared import ( create_mock_opportunity, find_link, + pagination_tester, product_test_spotlight, product_test_spotlight_async_opportunity, product_test_spotlight_sync_async_opportunity, product_test_spotlight_sync_opportunity, ) +from .test_datetime_interval import rfc3339_strftime @pytest.mark.parametrize("mock_products", [[product_test_spotlight]]) @@ -297,5 +300,79 @@ def test_new_search_location_header_matches_self_link( assert search_response.headers["Location"] == str(link["href"]) -# Pagination test for the OpportunitySearchRecrods returned from the /searches/opportunities -# endpoint on the root router +@pytest.mark.parametrize("mock_products", [[product_test_spotlight_async_opportunity]]) +def test_bad_ids(stapi_client_async_opportunity: TestClient) -> None: + search_record_id = "bad_id" + res = stapi_client_async_opportunity.get( + f"/searches/opportunities/{search_record_id}" + ) + assert res.status_code == status.HTTP_404_NOT_FOUND + + product_id = "test-spotlight" + opportunity_collection_id = "bad_id" + res = stapi_client_async_opportunity.get( + f"/products/{product_id}/opportunities/{opportunity_collection_id}" + ) + assert res.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.fixture +def setup_search_record_pagination( + stapi_client_async_opportunity: TestClient, + mock_products: list[Product], +) -> list[dict[str, Any]]: + product_id = "test-spotlight" + search_records = [] + for _ in range(3): + now = datetime.now(UTC) + end = now + timedelta(days=5) + format = "%Y-%m-%dT%H:%M:%S.%f%z" + start_string = rfc3339_strftime(now, format) + 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]}, + ], + }, + } + + response = stapi_client_async_opportunity.post( + f"/products/{product_id}/opportunities", json=opportunity_request + ) + assert response.status_code == 201 + + body = response.json() + search_records.append(body) + + return search_records + + +@pytest.mark.parametrize("limit", [0, 1, 2, 4]) +@pytest.mark.parametrize("mock_products", [[product_test_spotlight_async_opportunity]]) +def test_get_search_records_pagination( + stapi_client_async_opportunity: TestClient, + mock_products: list[Product], + setup_search_record_pagination: list[dict[str, Any]], + limit: int, +) -> None: + expected_returns = [] + if limit > 0: + expected_returns = setup_search_record_pagination + + pagination_tester( + stapi_client=stapi_client_async_opportunity, + url="/searches/opportunities", + method="GET", + limit=limit, + target="search_records", + expected_returns=expected_returns, + ) From f1e3086cdb48b01ca70d2a902c39b52220404173 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Thu, 6 Feb 2025 15:50:43 -0600 Subject: [PATCH 09/21] tests: Add a custom marker for supplying mock products --- pyproject.toml | 3 ++ tests/conftest.py | 4 ++- tests/test_opportunity_async.py | 52 +++++++++------------------------ tests/test_product.py | 2 +- 4 files changed, 21 insertions(+), 40 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 62ac7d4..31f57a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,9 @@ filterwarnings = [ "ignore:The 'app' shortcut is now deprecated.:DeprecationWarning", "ignore:Pydantic serializer warnings:UserWarning", ] +markers = [ + "mock_products", +] [build-system] requires = [ diff --git a/tests/conftest.py b/tests/conftest.py index 86d481d..f96aadc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,7 +41,9 @@ def base_url() -> Iterator[str]: @pytest.fixture -def mock_products() -> list[Product]: +def mock_products(request) -> list[Product]: + if request.node.get_closest_marker("mock_products") is not None: + return request.node.get_closest_marker("mock_products").args[0] return [ product_test_spotlight_sync_opportunity, product_test_satellite_provider_sync_opportunity, diff --git a/tests/test_opportunity_async.py b/tests/test_opportunity_async.py index 4c252b9..a3bba17 100644 --- a/tests/test_opportunity_async.py +++ b/tests/test_opportunity_async.py @@ -12,7 +12,6 @@ OpportunitySearchStatus, OpportunitySearchStatusCode, ) -from stapi_fastapi.models.product import Product from stapi_fastapi.models.shared import Link from .shared import ( @@ -27,10 +26,8 @@ from .test_datetime_interval import rfc3339_strftime -@pytest.mark.parametrize("mock_products", [[product_test_spotlight]]) -def test_no_opportunity_search_advertised( - stapi_client: TestClient, mock_products: list[Product] -) -> None: +@pytest.mark.mock_products([product_test_spotlight]) +def test_no_opportunity_search_advertised(stapi_client: TestClient) -> None: product_id = "test-spotlight" # the `/products/{productId}/opportunities link should not be advertised on the product @@ -44,10 +41,8 @@ def test_no_opportunity_search_advertised( assert find_link(root_body["links"], "opportunity-search-records") is None -@pytest.mark.parametrize("mock_products", [[product_test_spotlight_sync_opportunity]]) -def test_only_sync_search_advertised( - stapi_client: TestClient, mock_products: list[Product] -) -> None: +@pytest.mark.mock_products([product_test_spotlight_sync_opportunity]) +def test_only_sync_search_advertised(stapi_client: TestClient) -> None: product_id = "test-spotlight" # the `/products/{productId}/opportunities link should be advertised on the product @@ -69,9 +64,7 @@ def test_only_sync_search_advertised( [product_test_spotlight_sync_async_opportunity], ], ) -def test_async_search_advertised( - stapi_client_async_opportunity: TestClient, mock_products: list[Product] -) -> None: +def test_async_search_advertised(stapi_client_async_opportunity: TestClient) -> None: product_id = "test-spotlight" # the `/products/{productId}/opportunities link should be advertised on the product @@ -85,16 +78,10 @@ def test_async_search_advertised( assert find_link(root_body["links"], "opportunity-search-records") -def test_sync_search_response() -> None: - # handled in test_opportunity.py - pass - - -@pytest.mark.parametrize("mock_products", [[product_test_spotlight_async_opportunity]]) +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) def test_async_search_response( stapi_client_async_opportunity: TestClient, opportunity_search: dict[str, Any], - mock_products: list[Product], ) -> None: product_id = "test-spotlight" url = f"/products/{product_id}/opportunities" @@ -111,13 +98,10 @@ def test_async_search_response( assert find_link(body["links"], "self") -@pytest.mark.parametrize( - "mock_products", [[product_test_spotlight_sync_async_opportunity]] -) +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) def test_async_search_is_default( stapi_client_async_opportunity: TestClient, opportunity_search: dict[str, Any], - mock_products: list[Product], ) -> None: product_id = "test-spotlight" url = f"/products/{product_id}/opportunities" @@ -132,13 +116,10 @@ def test_async_search_is_default( pytest.fail("response is not an opportunity search record") -@pytest.mark.parametrize( - "mock_products", [[product_test_spotlight_sync_async_opportunity]] -) +@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) def test_prefer_header( stapi_client_async_opportunity: TestClient, opportunity_search: dict[str, Any], - mock_products: list[Product], ) -> None: product_id = "test-spotlight" url = f"/products/{product_id}/opportunities" @@ -170,11 +151,10 @@ def test_prefer_header( pytest.fail("response is not an opportunity search record") -@pytest.mark.parametrize("mock_products", [[product_test_spotlight_async_opportunity]]) +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) def test_async_search_record_retrieval( stapi_client_async_opportunity: TestClient, opportunity_search: dict[str, Any], - mock_products: list[Product], ) -> None: # post an async search product_id = "test-spotlight" @@ -201,11 +181,10 @@ def test_async_search_record_retrieval( ] -@pytest.mark.parametrize("mock_products", [[product_test_spotlight_async_opportunity]]) +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) def test_async_opportunity_search_to_completion( stapi_client_async_opportunity: TestClient, opportunity_search: dict[str, Any], - mock_products: list[Product], url_for: Callable[[str], str], ) -> None: # Post a request for an async search @@ -269,7 +248,7 @@ def test_async_opportunity_search_to_completion( ) # Verify we can retrieve the OpportunityCollection from the - # OpportunitySearchRecord's `opportunities` link, and the retrieved + # OpportunitySearchRecord's `opportunities` link; verify the retrieved # OpportunityCollection contains an order link and a link pointing back to the # OpportunitySearchRecord opportunities_link = next( @@ -283,11 +262,10 @@ def test_async_opportunity_search_to_completion( assert any(x for x in retrieved_collection.links if x.rel == "search-record") -@pytest.mark.parametrize("mock_products", [[product_test_spotlight_async_opportunity]]) +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) def test_new_search_location_header_matches_self_link( stapi_client_async_opportunity: TestClient, opportunity_search: dict[str, Any], - mock_products: list[Product], ) -> None: product_id = "test-spotlight" url = f"/products/{product_id}/opportunities" @@ -300,7 +278,7 @@ def test_new_search_location_header_matches_self_link( assert search_response.headers["Location"] == str(link["href"]) -@pytest.mark.parametrize("mock_products", [[product_test_spotlight_async_opportunity]]) +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) def test_bad_ids(stapi_client_async_opportunity: TestClient) -> None: search_record_id = "bad_id" res = stapi_client_async_opportunity.get( @@ -319,7 +297,6 @@ def test_bad_ids(stapi_client_async_opportunity: TestClient) -> None: @pytest.fixture def setup_search_record_pagination( stapi_client_async_opportunity: TestClient, - mock_products: list[Product], ) -> list[dict[str, Any]]: product_id = "test-spotlight" search_records = [] @@ -357,10 +334,9 @@ def setup_search_record_pagination( @pytest.mark.parametrize("limit", [0, 1, 2, 4]) -@pytest.mark.parametrize("mock_products", [[product_test_spotlight_async_opportunity]]) +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) def test_get_search_records_pagination( stapi_client_async_opportunity: TestClient, - mock_products: list[Product], setup_search_record_pagination: list[dict[str, Any]], limit: int, ) -> None: diff --git a/tests/test_product.py b/tests/test_product.py index 8c19d89..20c3316 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -124,7 +124,7 @@ def test_token_not_found(stapi_client: TestClient) -> None: assert res.status_code == status.HTTP_404_NOT_FOUND -@pytest.mark.parametrize("mock_products", [[]]) +@pytest.mark.mock_products([]) def test_no_products(stapi_client: TestClient): res = stapi_client.get("/products") body = res.json() From 73741b95b4609abc8f0606913c1bfaa2cd2b6e1b Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Thu, 6 Feb 2025 16:01:42 -0600 Subject: [PATCH 10/21] docs: update CHANGELOG --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05fbcbf..5a304a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Added token-based pagination to `GET /orders`, `GET /products`, `GET /orders/{order_id}/statuses`, and `POST /products/{product_id}/opportunities`. +- Asynchronous opportunity search. If the root router supports asynchronous opportunity + search, all products must support it. If asynchronouse opportunity search is + supported, `POST` requests to the `/products/{productId}/opportunities` endpoint will + default to asynchronous opportunity search unless synchronous searchis also supported + by the `product` and a `Prefer` header in the `POST` request is set to `wait`. +- Added the `/products/{productId}/opportunities/` and `/searches/opportunities` + endpoints to support asynchronous opportunity search. ### Changed From 3ec697849932a8a6b0858b2361aab212d591913d Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Thu, 6 Feb 2025 20:21:58 -0600 Subject: [PATCH 11/21] fix: fix db mutation bug, sundry fixes --- src/stapi_fastapi/backends/root_backend.py | 8 +++--- src/stapi_fastapi/routers/product_router.py | 17 +++++++------ src/stapi_fastapi/routers/root_router.py | 27 ++++++++++++--------- tests/backends.py | 24 +++++++++++------- tests/shared.py | 17 ++++++++++++- tests/test_order.py | 15 +++--------- 6 files changed, 63 insertions(+), 45 deletions(-) diff --git a/src/stapi_fastapi/backends/root_backend.py b/src/stapi_fastapi/backends/root_backend.py index 3d5edfc..b7c4bd2 100644 --- a/src/stapi_fastapi/backends/root_backend.py +++ b/src/stapi_fastapi/backends/root_backend.py @@ -50,7 +50,7 @@ GetOrderStatuses = Callable[ [str, str | None, int, Request], - Coroutine[Any, Any, ResultE[tuple[list[T], Maybe[str]]]], + 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`. @@ -64,9 +64,9 @@ Returns: A tuple containing a list of order statuses and a pagination token. - - Should return returns.result.Success[tuple[list[OrderStatus], returns.maybe.Some[str]] if order is found and including a pagination token. - - Should return returns.result.Success[tuple[list[OrderStatus], returns.maybe.Nothing]] if order is found and not including a pagination token. - - Should return returns.result.Failure[Exception] if the order is not found or if access is denied. + - 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. """ diff --git a/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py index 02f02a4..bd1dd8a 100644 --- a/src/stapi_fastapi/routers/product_router.py +++ b/src/stapi_fastapi/routers/product_router.py @@ -160,6 +160,7 @@ async def _create_order( endpoint=self.get_opportunity_collection, name=f"{self.root_router.name}:{self.product.id}:get-opportunity-collection", methods=["GET"], + response_class=GeoJSONResponse, summary="Get an Opportunity Collection by ID", tags=["Products"], ) @@ -268,11 +269,13 @@ async def search_opportunities_sync( search.limit, request, ): - case Success((features, Some(pagination_token))): - links.append(self.order_link(request, search)) - links.append(self.pagination_link(request, search, pagination_token)) - case Success((features, Nothing)): # noqa: F841 + case Success((features, maybe_pagination_token)): links.append(self.order_link(request, search)) + match maybe_pagination_token: + case Some(x): + links.append(self.pagination_link(request, search, x)) + case Maybe.empty: + pass case Failure(e) if isinstance(e, ConstraintsException): raise e case Failure(e): @@ -402,7 +405,7 @@ def pagination_link( async def get_opportunity_collection( self: Self, opportunity_collection_id: str, request: Request - ) -> Response: + ) -> OpportunityCollection: """ Fetch an opportunity collection generated by an asynchronous opportunity search. """ @@ -424,9 +427,7 @@ async def get_opportunity_collection( type=TYPE_JSON, ), ) - return GeoJSONResponse( - content=opportunity_collection.model_dump(mode="json") - ) + return opportunity_collection case Success(Maybe.empty): raise NotFoundException("Opportunity Collection not found") case Failure(e): diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index abab3b9..7b20128 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -299,12 +299,16 @@ async def get_order_statuses( ) -> OrderStatuses: links: list[Link] = [] match await self._get_order_statuses(order_id, next, limit, request): - case Success((statuses, Some(pagination_token))): + case Success(Some((statuses, maybe_pagination_token))): links.append(self.order_statuses_link(request, order_id)) - links.append(self.pagination_link(request, pagination_token, limit)) - case Success((statuses, Nothing)): # noqa: F841 - links.append(self.order_statuses_link(request, order_id)) - case Failure(KeyError()): + match maybe_pagination_token: + case Some(x): + links.append(self.pagination_link(request, x, limit)) + case Maybe.empty: + pass + case Success(Maybe.empty): + raise NotFoundException("Order not found") + case Failure(ValueError()): raise NotFoundException("Error finding pagination token") case Failure(e): logger.error( @@ -374,17 +378,16 @@ async def get_opportunity_search_records( ) -> OpportunitySearchRecords: links: list[Link] = [] match await self._get_opportunity_search_records(next, limit, request): - case Success((records, Some(pagination_token))): - for record in records: - record.links.append( - self.opportunity_search_record_self_link(record, request) - ) - links.append(self.pagination_link(request, pagination_token, limit)) - case Success((records, Nothing)): # noqa: F841 + case Success((records, maybe_pagination_token)): for record in records: record.links.append( self.opportunity_search_record_self_link(record, request) ) + match maybe_pagination_token: + case Some(x): + links.append(self.pagination_link(request, x, limit)) + case Maybe.empty: + pass case Failure(ValueError()): raise NotFoundException(detail="Error finding pagination token") case Failure(e): diff --git a/tests/backends.py b/tests/backends.py index 7a7c4b5..d34a08d 100644 --- a/tests/backends.py +++ b/tests/backends.py @@ -39,7 +39,7 @@ async def mock_get_orders( start = order_ids.index(next) end = start + limit ids = order_ids[start:end] - orders = [request.state._orders_db._orders[order_id] for order_id in ids] + orders = [request.state._orders_db.get_order(order_id) for order_id in ids] if end > 0 and end < len(order_ids): return Success( @@ -54,17 +54,23 @@ async def mock_get_order(order_id: str, request: Request) -> ResultE[Maybe[Order """ Show details for order with `order_id`. """ - - return Success(Maybe.from_optional(request.state._orders_db._orders.get(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[tuple[list[OrderStatus], Maybe[str]]]: +) -> ResultE[Maybe[tuple[list[OrderStatus], Maybe[str]]]]: try: start = 0 limit = min(limit, 100) - statuses = request.state._orders_db._statuses[order_id] + statuses = request.state._orders_db.get_order_statuses(order_id) + if statuses is None: + return Success(Nothing) if next: start = int(next) @@ -72,8 +78,8 @@ async def mock_get_order_statuses( stati = statuses[start:end] if end > 0 and end < len(statuses): - return Success((stati, Some(str(end)))) - return Success((stati, Nothing)) + return Success(Some((stati, Some(str(end))))) + return Success(Some((stati, Nothing))) except Exception as e: return Failure(e) @@ -110,8 +116,8 @@ async def mock_create_order( links=[], ) - request.state._orders_db._orders[order.id] = order - request.state._orders_db._statuses[order.id].insert(0, status) + request.state._orders_db.put_order(order) + request.state._orders_db.put_order_status(order.id, status) return Success(order) except Exception as e: return Failure(e) diff --git a/tests/shared.py b/tests/shared.py index 83ed715..9ad7877 100644 --- a/tests/shared.py +++ b/tests/shared.py @@ -49,6 +49,21 @@ def __init__(self) -> None: self._orders: dict[str, Order] = {} self._statuses: dict[str, list[OrderStatus]] = defaultdict(list) + def get_order(self, order_id: str) -> Order | None: + return deepcopy(self._orders.get(order_id)) + + def get_orders(self) -> list[Order]: + return deepcopy(list(self._orders.values())) + + def put_order(self, order: Order) -> None: + self._orders[order.id] = deepcopy(order) + + def get_order_statuses(self, order_id: str) -> list[OrderStatus] | None: + return deepcopy(self._statuses.get(order_id)) + + def put_order_status(self, order_id: str, status: OrderStatus) -> None: + self._statuses[order_id].append(deepcopy(status)) + class InMemoryOpportunityDB: def __init__(self) -> None: @@ -275,7 +290,7 @@ def make_request( o = urlparse(url) base_url = f"{o.scheme}://{o.netloc}{o.path}" parsed_qs = parse_qs(o.query) - params = {} + params: dict[str, Any] = {} if "next" in parsed_qs: params["next"] = parsed_qs["next"][0] params["limit"] = int(parsed_qs.get("limit", [None])[0] or limit) diff --git a/tests/test_order.py b/tests/test_order.py index 5ef6203..cf4cc9d 100644 --- a/tests/test_order.py +++ b/tests/test_order.py @@ -1,4 +1,3 @@ -import copy from datetime import UTC, datetime, timedelta, timezone import pytest @@ -171,15 +170,7 @@ def test_get_orders_pagination( ) -> None: expected_returns = [] if limit > 0: - for order in setup_orders_pagination: - self_link = copy.deepcopy(order["links"][0]) - order["links"].append(self_link) - monitor_link = copy.deepcopy(order["links"][0]) - monitor_link["rel"] = "monitor" - monitor_link["type"] = "application/json" - monitor_link["href"] = monitor_link["href"] + "/statuses" - order["links"].append(monitor_link) - expected_returns.append(order) + expected_returns = setup_orders_pagination pagination_tester( stapi_client=stapi_client, @@ -228,7 +219,9 @@ def test_get_order_status_pagination( stapi_client: TestClient, order_statuses: dict[str, list[OrderStatus]], ) -> None: - stapi_client.app_state["_orders_db"]._statuses = order_statuses + for id, statuses in order_statuses.items(): + for s in statuses: + stapi_client.app_state["_orders_db"].put_order_status(id, s) order_id = "test_order_id" expected_returns = [] From 8f1366d012489ea07270e247fb2679021fcd5c72 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Sat, 8 Feb 2025 06:13:25 -0600 Subject: [PATCH 12/21] review: class attributes always defined, remove implicit Self type --- src/stapi_fastapi/models/product.py | 55 +++++++++++++------ src/stapi_fastapi/models/shared.py | 4 +- src/stapi_fastapi/routers/product_router.py | 24 ++++---- src/stapi_fastapi/routers/root_router.py | 61 ++++++++++++--------- 4 files changed, 89 insertions(+), 55 deletions(-) diff --git a/src/stapi_fastapi/models/product.py b/src/stapi_fastapi/models/product.py index e570197..b17a4bd 100644 --- a/src/stapi_fastapi/models/product.py +++ b/src/stapi_fastapi/models/product.py @@ -56,6 +56,9 @@ class Product(BaseModel): _opportunity_properties: type[OpportunityProperties] _order_parameters: type[OrderParameters] _create_order: CreateOrder + _search_opportunities: SearchOpportunities | None + _search_opportunities_async: SearchOpportunitiesAsync | None + _get_opportunity_collection: GetOpportunityCollection | None def __init__( self, @@ -81,40 +84,60 @@ def __init__( self._opportunity_properties = opportunity_properties self._order_parameters = order_parameters self._create_order = create_order - if search_opportunities is not None: - self._search_opportunities = search_opportunities - if search_opportunities_async is not None: - self._search_opportunities_async = search_opportunities_async - if get_opportunity_collection is not None: - self._get_opportunity_collection = get_opportunity_collection + self._search_opportunities = search_opportunities + self._search_opportunities_async = search_opportunities_async + self._get_opportunity_collection = get_opportunity_collection @property - def create_order(self: Self) -> CreateOrder: + def create_order(self) -> CreateOrder: return self._create_order @property - def constraints(self: Self) -> type[Constraints]: + def search_opportunities(self) -> SearchOpportunities: + if not self._search_opportunities: + raise AttributeError("This product does not support opportunity search") + return self._search_opportunities + + @property + def search_opportunities_async(self) -> SearchOpportunitiesAsync: + if not self._search_opportunities_async: + raise AttributeError( + "This product does not support async opportunity search" + ) + return self._search_opportunities_async + + @property + def get_opportunity_collection(self) -> GetOpportunityCollection: + if not self._get_opportunity_collection: + raise AttributeError( + "This product does not support async opportunity search" + ) + return self._get_opportunity_collection + + @property + def constraints(self) -> type[Constraints]: return self._constraints @property - def opportunity_properties(self: Self) -> type[OpportunityProperties]: + def opportunity_properties(self) -> type[OpportunityProperties]: return self._opportunity_properties @property - def order_parameters(self: Self) -> type[OrderParameters]: + def order_parameters(self) -> type[OrderParameters]: return self._order_parameters @property - def supports_opportunity_search(self: Self) -> bool: - return hasattr(self, "_search_opportunities") + def supports_opportunity_search(self) -> bool: + return self._search_opportunities is not None @property - def supports_async_opportunity_search(self: Self) -> bool: - return hasattr(self, "_search_opportunities_async") and hasattr( - self, "_get_opportunity_collection" + def supports_async_opportunity_search(self) -> bool: + return ( + self._search_opportunities_async is not None + and self._get_opportunity_collection is not None ) - def with_links(self: Self, links: list[Link] | None = None) -> Self: + def with_links(self, links: list[Link] | None = None) -> Self: if not links: return self diff --git a/src/stapi_fastapi/models/shared.py b/src/stapi_fastapi/models/shared.py index f67c4e7..6daca05 100644 --- a/src/stapi_fastapi/models/shared.py +++ b/src/stapi_fastapi/models/shared.py @@ -1,4 +1,4 @@ -from typing import Any, Self +from typing import Any from pydantic import ( AnyUrl, @@ -28,5 +28,5 @@ def __init__(self, href: AnyUrl | str, **kwargs): # overriding the default serialization to filter None field values from # dumped json @model_serializer(mode="wrap", when_used="json") - def serialize(self: Self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + 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/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py index bd1dd8a..dddf505 100644 --- a/src/stapi_fastapi/routers/product_router.py +++ b/src/stapi_fastapi/routers/product_router.py @@ -2,7 +2,7 @@ import logging import traceback -from typing import TYPE_CHECKING, Self +from typing import TYPE_CHECKING from fastapi import ( APIRouter, @@ -53,7 +53,7 @@ def get_preference(prefer: str | None = Header(None)) -> str | None: class ProductRouter(APIRouter): def __init__( - self: Self, + self, product: Product, root_router: RootRouter, *args, @@ -165,7 +165,7 @@ async def _create_order( tags=["Products"], ) - def get_product(self: Self, request: Request) -> Product: + def get_product(self, request: Request) -> Product: links = [ Link( href=str( @@ -224,7 +224,7 @@ def get_product(self: Self, request: Request) -> Product: return self.product.with_links(links=links) async def search_opportunities( - self: Self, + self, search: OpportunityPayload, request: Request, response: Response, @@ -255,14 +255,14 @@ async def search_opportunities( raise AssertionError("Expected code to be unreachable") async def search_opportunities_sync( - self: Self, + self, search: OpportunityPayload, request: Request, response: Response, prefer: Prefer | None, ) -> OpportunityCollection: links: list[Link] = [] - match await self.product._search_opportunities( + match await self.product.search_opportunities( self, search, search.next, @@ -296,12 +296,12 @@ async def search_opportunities_sync( return OpportunityCollection(features=features, links=links) async def search_opportunities_async( - self: Self, + self, search: OpportunityPayload, request: Request, prefer: Prefer | None, ) -> JSONResponse: - match await self.product._search_opportunities_async(self, search, request): + 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( @@ -335,13 +335,13 @@ async def search_opportunities_async( case x: raise AssertionError(f"Expected code to be unreachable: {x}") - def get_product_constraints(self: Self) -> JsonSchemaModel: + def get_product_constraints(self) -> JsonSchemaModel: """ Return supported constraints of a specific product """ return self.product.constraints - def get_product_order_parameters(self: Self) -> JsonSchemaModel: + def get_product_order_parameters(self) -> JsonSchemaModel: """ Return supported constraints of a specific product """ @@ -404,12 +404,12 @@ def pagination_link( ) async def get_opportunity_collection( - self: Self, opportunity_collection_id: str, request: Request + self, opportunity_collection_id: str, request: Request ) -> OpportunityCollection: """ Fetch an opportunity collection generated by an asynchronous opportunity search. """ - match await self.product._get_opportunity_collection( + match await self.product.get_opportunity_collection( self, opportunity_collection_id, request, diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index 7b20128..e0076ea 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -1,6 +1,5 @@ import logging import traceback -from typing import Self from fastapi import APIRouter, HTTPException, Request, status from fastapi.datastructures import URL @@ -41,7 +40,7 @@ class RootRouter(APIRouter): def __init__( - self: Self, + self, get_orders: GetOrders, get_order: GetOrder, get_order_statuses: GetOrderStatuses, @@ -67,10 +66,8 @@ def __init__( self._get_orders = get_orders self._get_order = get_order self._get_order_statuses = get_order_statuses - if get_opportunity_search_records is not None: - self._get_opportunity_search_records = get_opportunity_search_records - if get_opportunity_search_record is not None: - self._get_opportunity_search_record = get_opportunity_search_record + self.__get_opportunity_search_records = get_opportunity_search_records + self.__get_opportunity_search_record = get_opportunity_search_record self.conformances = conformances self.name = name self.openapi_endpoint_name = openapi_endpoint_name @@ -152,7 +149,7 @@ def __init__( tags=["Opportunities"], ) - def get_root(self: Self, request: Request) -> RootResponse: + def get_root(self, request: Request) -> RootResponse: links = [ Link( href=str(request.url_for(f"{self.name}:root")), @@ -204,11 +201,11 @@ def get_root(self: Self, request: Request) -> RootResponse: links=links, ) - def get_conformance(self: Self) -> Conformance: + def get_conformance(self) -> Conformance: return Conformance(conforms_to=self.conformances) def get_products( - self: Self, request: Request, next: str | None = None, limit: int = 10 + self, request: Request, next: str | None = None, limit: int = 10 ) -> ProductsCollection: start = 0 limit = min(limit, 100) @@ -240,7 +237,7 @@ def get_products( ) async def get_orders( - self: Self, request: Request, next: str | None = None, limit: int = 10 + self, request: Request, next: str | None = None, limit: int = 10 ) -> OrderCollection: links: list[Link] = [] match await self._get_orders(next, limit, request): @@ -267,7 +264,7 @@ async def get_orders( raise AssertionError("Expected code to be unreachable") return OrderCollection(features=orders, links=links) - async def get_order(self: Self, order_id: str, request: Request) -> Order: + async def get_order(self, order_id: str, request: Request) -> Order: """ Get details for order with `order_id`. """ @@ -291,7 +288,7 @@ async def get_order(self: Self, order_id: str, request: Request) -> Order: raise AssertionError("Expected code to be unreachable") async def get_order_statuses( - self: Self, + self, order_id: str, request: Request, next: str | None = None, @@ -323,22 +320,20 @@ async def get_order_statuses( raise AssertionError("Expected code to be unreachable") return OrderStatuses(statuses=statuses, links=links) - def add_product(self: Self, product: Product, *args, **kwargs) -> None: + def add_product(self, product: Product, *args, **kwargs) -> None: # Give the include a prefix from the product router product_router = ProductRouter(product, self, *args, **kwargs) self.include_router(product_router, prefix=f"/products/{product.id}") self.product_routers[product.id] = product_router self.product_ids = [*self.product_routers.keys()] - def generate_order_href(self: Self, request: Request, order_id: str) -> URL: + def generate_order_href(self, request: Request, order_id: str) -> URL: return request.url_for(f"{self.name}:get-order", order_id=order_id) - def generate_order_statuses_href( - self: Self, request: Request, order_id: str - ) -> URL: + def generate_order_statuses_href(self, request: Request, order_id: str) -> URL: return request.url_for(f"{self.name}:list-order-statuses", order_id=order_id) - def order_links(self: Self, order: Order, request: Request) -> list[Link]: + def order_links(self, order: Order, request: Request) -> list[Link]: return [ Link( href=str(self.generate_order_href(request, order.id)), @@ -374,7 +369,7 @@ def pagination_link(self, request: Request, pagination_token: str, limit: int): ) async def get_opportunity_search_records( - self: Self, request: Request, next: str | None = None, limit: int = 10 + self, request: Request, next: str | None = None, limit: int = 10 ) -> OpportunitySearchRecords: links: list[Link] = [] match await self._get_opportunity_search_records(next, limit, request): @@ -404,7 +399,7 @@ async def get_opportunity_search_records( return OpportunitySearchRecords(search_records=records, links=links) async def get_opportunity_search_record( - self: Self, search_record_id: str, request: Request + self, search_record_id: str, request: Request ) -> OpportunitySearchRecord: """ Get the Opportunity Search Record with `search_record_id`. @@ -431,7 +426,7 @@ async def get_opportunity_search_record( raise AssertionError("Expected code to be unreachable") def generate_opportunity_search_record_href( - self: Self, request: Request, search_record_id: str + self, request: Request, search_record_id: str ) -> URL: return request.url_for( f"{self.name}:get-opportunity-search-record", @@ -439,7 +434,7 @@ def generate_opportunity_search_record_href( ) def opportunity_search_record_self_link( - self: Self, opportunity_search_record: OpportunitySearchRecord, request: Request + self, opportunity_search_record: OpportunitySearchRecord, request: Request ) -> Link: return Link( href=str( @@ -452,9 +447,25 @@ def opportunity_search_record_self_link( ) @property - def supports_async_opportunity_search(self: Self) -> bool: + def _get_opportunity_search_records(self) -> GetOpportunitySearchRecords: + if not self.__get_opportunity_search_records: + raise AttributeError( + "Root router does not support async opportunity search" + ) + return self.__get_opportunity_search_records + + @property + def _get_opportunity_search_record(self) -> GetOpportunitySearchRecord: + if not self.__get_opportunity_search_record: + raise AttributeError( + "Root router does not support async opportunity search" + ) + return self.__get_opportunity_search_record + + @property + def supports_async_opportunity_search(self) -> bool: return ( ASYNC_OPPORTUNITIES in self.conformances - and hasattr(self, "_get_opportunity_search_records") - and hasattr(self, "_get_opportunity_search_record") + and self._get_opportunity_search_records is not None + and self._get_opportunity_search_record is not None ) From 51166f6adde90381a39e6e03f11c692dc64d78fc Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Sat, 8 Feb 2025 06:22:52 -0600 Subject: [PATCH 13/21] review: Rename get_preference to get_prefer --- src/stapi_fastapi/routers/product_router.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py index dddf505..4bacd77 100644 --- a/src/stapi_fastapi/routers/product_router.py +++ b/src/stapi_fastapi/routers/product_router.py @@ -38,7 +38,7 @@ logger = logging.getLogger(__name__) -def get_preference(prefer: str | None = Header(None)) -> str | None: +def get_prefer(prefer: str | None = Header(None)) -> str | None: if prefer is None: return None @@ -228,7 +228,7 @@ async def search_opportunities( search: OpportunityPayload, request: Request, response: Response, - prefer: Prefer | None = Depends(get_preference), + prefer: Prefer | None = Depends(get_prefer), ) -> OpportunityCollection | Response: """ Explore the opportunities available for a particular set of constraints From c719d8db91b411f1f39cf19d377a9ef2507e5e13 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Sat, 8 Feb 2025 06:35:57 -0600 Subject: [PATCH 14/21] review: sidecar fix for logging -> logger --- src/stapi_fastapi/routers/root_router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index e0076ea..e57e4b4 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -213,7 +213,7 @@ def get_products( if next: start = self.product_ids.index(next) except ValueError: - logging.exception("An error occurred while retrieving products") + logger.exception("An error occurred while retrieving products") raise NotFoundException( detail="Error finding pagination token for products" ) from None From ae1a6705ba883fa2530acbace1bf4caa5c8a5ed1 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Sat, 8 Feb 2025 06:45:52 -0600 Subject: [PATCH 15/21] review: logging.error -> logging.exception --- src/stapi_fastapi/routers/product_router.py | 8 ++++---- src/stapi_fastapi/routers/root_router.py | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py index 4bacd77..6dd781f 100644 --- a/src/stapi_fastapi/routers/product_router.py +++ b/src/stapi_fastapi/routers/product_router.py @@ -279,7 +279,7 @@ async def search_opportunities_sync( case Failure(e) if isinstance(e, ConstraintsException): raise e case Failure(e): - logger.error( + logger.exception( "An error occurred while searching opportunities: %s", traceback.format_exception(e), ) @@ -324,7 +324,7 @@ async def search_opportunities_async( case Failure(e) if isinstance(e, ConstraintsException): raise e case Failure(e): - logger.error( + logger.exception( "An error occurred while initiating an asynchronous opportunity search: %s", traceback.format_exception(e), ) @@ -366,7 +366,7 @@ async def create_order( case Failure(e) if isinstance(e, ConstraintsException): raise e case Failure(e): - logger.error( + logger.exception( "An error occurred while creating order: %s", traceback.format_exception(e), ) @@ -431,7 +431,7 @@ async def get_opportunity_collection( case Success(Maybe.empty): raise NotFoundException("Opportunity Collection not found") case Failure(e): - logger.error( + logger.exception( "An error occurred while fetching opportunity collection: '%s': %s", opportunity_collection_id, traceback.format_exception(e), diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index e57e4b4..670a72e 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -252,7 +252,7 @@ async def get_orders( case Failure(ValueError()): raise NotFoundException(detail="Error finding pagination token") case Failure(e): - logger.error( + logger.exception( "An error occurred while retrieving orders: %s", traceback.format_exception(e), ) @@ -275,7 +275,7 @@ async def get_order(self, order_id: str, request: Request) -> Order: case Success(Maybe.empty): raise NotFoundException("Order not found") case Failure(e): - logger.error( + logger.exception( "An error occurred while retrieving order '%s': %s", order_id, traceback.format_exception(e), @@ -308,7 +308,7 @@ async def get_order_statuses( case Failure(ValueError()): raise NotFoundException("Error finding pagination token") case Failure(e): - logger.error( + logger.exception( "An error occurred while retrieving order statuses: %s", traceback.format_exception(e), ) @@ -386,7 +386,7 @@ async def get_opportunity_search_records( case Failure(ValueError()): raise NotFoundException(detail="Error finding pagination token") case Failure(e): - logger.error( + logger.exception( "An error occurred while retrieving opportunity search records: %s", traceback.format_exception(e), ) @@ -413,7 +413,7 @@ async def get_opportunity_search_record( case Success(Maybe.empty): raise NotFoundException("Opportunity Search Record not found") case Failure(e): - logger.error( + logger.exception( "An error occurred while retrieving opportunity search record '%s': %s", search_record_id, traceback.format_exception(e), From 822dae964733ad37855b750a5318d1219ab499f9 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Sat, 8 Feb 2025 06:59:27 -0600 Subject: [PATCH 16/21] review: correct list-orders type to geo+json --- src/stapi_fastapi/routers/root_router.py | 2 +- tests/test_root.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index 670a72e..467f861 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -169,7 +169,7 @@ def get_root(self, request: Request) -> RootResponse: Link( href=str(request.url_for(f"{self.name}:list-orders")), rel="orders", - type=TYPE_JSON, + type=TYPE_GEOJSON, ), Link( href=str(request.url_for(self.openapi_endpoint_name)), diff --git a/tests/test_root.py b/tests/test_root.py index fa72102..00d587a 100644 --- a/tests/test_root.py +++ b/tests/test_root.py @@ -19,4 +19,4 @@ def test_root(stapi_client: TestClient, assert_link) -> None: assert_link("GET /", body, "service-docs", "/docs", media_type="text/html") assert_link("GET /", body, "conformance", "/conformance") assert_link("GET /", body, "products", "/products") - assert_link("GET /", body, "orders", "/orders") + assert_link("GET /", body, "orders", "/orders", media_type="application/geo+json") From e903c1655116e372245416654c5d3aa3daa22450 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Sat, 8 Feb 2025 07:00:39 -0600 Subject: [PATCH 17/21] review: sidecar - remove unused STAPI_VERSION --- src/stapi_fastapi/constants.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/stapi_fastapi/constants.py b/src/stapi_fastapi/constants.py index a16c982..0fc2f4d 100644 --- a/src/stapi_fastapi/constants.py +++ b/src/stapi_fastapi/constants.py @@ -1,3 +1,2 @@ -STAPI_VERSION = "0.0.0.pre" TYPE_JSON = "application/json" TYPE_GEOJSON = "application/geo+json" From df8003c4b60b9d68a268e847e17fbcde94b6e861 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Mon, 10 Feb 2025 10:17:19 -0600 Subject: [PATCH 18/21] review: Use Generator instead of Iterator --- tests/conftest.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f96aadc..d9b80a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -from collections.abc import AsyncIterator, Iterator +from collections.abc import AsyncIterator, Generator, Iterator from contextlib import asynccontextmanager from datetime import UTC, datetime, timedelta from typing import Any, Callable @@ -60,7 +60,7 @@ def stapi_client( mock_products: list[Product], base_url: str, mock_opportunities: list[Opportunity], -) -> Iterator[TestClient]: +) -> Generator[TestClient, None, None]: @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: try: @@ -93,7 +93,7 @@ def stapi_client_async_opportunity( mock_products: list[Product], base_url: str, mock_opportunities: list[Opportunity], -) -> Iterator[TestClient]: +) -> Generator[TestClient, None, None]: @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: try: From 34c248bc2e5d4eceb127729471caee8ca60c4804 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Mon, 10 Feb 2025 10:25:11 -0600 Subject: [PATCH 19/21] review: correct logger.exception/logger.error use --- src/stapi_fastapi/routers/product_router.py | 8 ++++---- src/stapi_fastapi/routers/root_router.py | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py index 6dd781f..4bacd77 100644 --- a/src/stapi_fastapi/routers/product_router.py +++ b/src/stapi_fastapi/routers/product_router.py @@ -279,7 +279,7 @@ async def search_opportunities_sync( case Failure(e) if isinstance(e, ConstraintsException): raise e case Failure(e): - logger.exception( + logger.error( "An error occurred while searching opportunities: %s", traceback.format_exception(e), ) @@ -324,7 +324,7 @@ async def search_opportunities_async( case Failure(e) if isinstance(e, ConstraintsException): raise e case Failure(e): - logger.exception( + logger.error( "An error occurred while initiating an asynchronous opportunity search: %s", traceback.format_exception(e), ) @@ -366,7 +366,7 @@ async def create_order( case Failure(e) if isinstance(e, ConstraintsException): raise e case Failure(e): - logger.exception( + logger.error( "An error occurred while creating order: %s", traceback.format_exception(e), ) @@ -431,7 +431,7 @@ async def get_opportunity_collection( case Success(Maybe.empty): raise NotFoundException("Opportunity Collection not found") case Failure(e): - logger.exception( + logger.error( "An error occurred while fetching opportunity collection: '%s': %s", opportunity_collection_id, traceback.format_exception(e), diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index 467f861..f435702 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -252,7 +252,7 @@ async def get_orders( case Failure(ValueError()): raise NotFoundException(detail="Error finding pagination token") case Failure(e): - logger.exception( + logger.error( "An error occurred while retrieving orders: %s", traceback.format_exception(e), ) @@ -275,7 +275,7 @@ async def get_order(self, order_id: str, request: Request) -> Order: case Success(Maybe.empty): raise NotFoundException("Order not found") case Failure(e): - logger.exception( + logger.error( "An error occurred while retrieving order '%s': %s", order_id, traceback.format_exception(e), @@ -308,7 +308,7 @@ async def get_order_statuses( case Failure(ValueError()): raise NotFoundException("Error finding pagination token") case Failure(e): - logger.exception( + logger.error( "An error occurred while retrieving order statuses: %s", traceback.format_exception(e), ) @@ -386,7 +386,7 @@ async def get_opportunity_search_records( case Failure(ValueError()): raise NotFoundException(detail="Error finding pagination token") case Failure(e): - logger.exception( + logger.error( "An error occurred while retrieving opportunity search records: %s", traceback.format_exception(e), ) @@ -413,7 +413,7 @@ async def get_opportunity_search_record( case Success(Maybe.empty): raise NotFoundException("Opportunity Search Record not found") case Failure(e): - logger.exception( + logger.error( "An error occurred while retrieving opportunity search record '%s': %s", search_record_id, traceback.format_exception(e), From c47307d32ab244d08badff625770e1df9a20174d Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Mon, 10 Feb 2025 10:36:30 -0600 Subject: [PATCH 20/21] review: simplify root router links --- src/stapi_fastapi/routers/root_router.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index f435702..af0571c 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -156,6 +156,16 @@ def get_root(self, request: Request) -> RootResponse: rel="self", type=TYPE_JSON, ), + Link( + href=str(request.url_for(self.openapi_endpoint_name)), + rel="service-description", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(self.docs_endpoint_name)), + rel="service-docs", + type="text/html", + ), Link( href=str(request.url_for(f"{self.name}:conformance")), rel="conformance", @@ -171,21 +181,10 @@ def get_root(self, request: Request) -> RootResponse: rel="orders", type=TYPE_GEOJSON, ), - Link( - href=str(request.url_for(self.openapi_endpoint_name)), - rel="service-description", - type=TYPE_JSON, - ), - Link( - href=str(request.url_for(self.docs_endpoint_name)), - rel="service-docs", - type="text/html", - ), ] if self.supports_async_opportunity_search: - links.insert( - -2, + links.append( Link( href=str( request.url_for(f"{self.name}:list-opportunity-search-records") From 3ac327dd9ec63cb6e6366dc12a3dbe04a712f760 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Mon, 10 Feb 2025 10:42:00 -0600 Subject: [PATCH 21/21] fix: minor typo fixes in CHANGELOG --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec89576..edb0696 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Optional and Extension STAPI Status Codes "scheduled", "held", "processing", "reserved", "tasked", and "user_cancelled" - Asynchronous opportunity search. If the root router supports asynchronous opportunity - search, all products must support it. If asynchronouse opportunity search is + search, all products must support it. If asynchronous opportunity search is supported, `POST` requests to the `/products/{productId}/opportunities` endpoint will - default to asynchronous opportunity search unless synchronous searchis also supported + default to asynchronous opportunity search unless synchronous search is also supported by the `product` and a `Prefer` header in the `POST` request is set to `wait`. - Added the `/products/{productId}/opportunities/` and `/searches/opportunities` endpoints to support asynchronous opportunity search.