diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f3d18e..edb0696 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,15 @@ 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`. -- Optional and Extension STAPI Status Codes "scheduled", "held", "processing", "reserved", "tasked", - and "user_cancelled" +- 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 asynchronous opportunity search is + supported, `POST` requests to the `/products/{productId}/opportunities` endpoint will + 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. ### Changed 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/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 20479de..c2309ff 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, OpportunityPayload +from stapi_fastapi.models.opportunity import ( + Opportunity, + OpportunityCollection, + OpportunityPayload, + OpportunitySearchRecord, +) from stapi_fastapi.models.order import Order, OrderPayload from stapi_fastapi.routers.product_router import ProductRouter @@ -20,7 +25,7 @@ Args: product_router (ProductRouter): The product router. - search (OpportunityRequest): The search parameters. + search (OpportunityPayload): The search parameters. next (str | None): A pagination token. limit (int): The maximum number of opportunities to return in a page. request (Request): FastAPI's Request object. @@ -37,6 +42,48 @@ returns.result.Failure[stapi_fastapi.exceptions.ConstraintsException] if not valid. """ +SearchOpportunitiesAsync = Callable[ + [ProductRouter, OpportunityPayload, 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 (OpportunityPayload): 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..b7c4bd2 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. """ @@ -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,8 +64,43 @@ 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. +""" + +GetOpportunitySearchRecords = Callable[ + [str | None, int, Request], + 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. """ 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" 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 8f7d7f5..0257694 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 Any, 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 @@ -45,3 +46,38 @@ 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 + opportunity_request: OpportunityPayload + status: OpportunitySearchStatus + links: list[Link] = Field(default_factory=list) + + +class OpportunitySearchRecords(BaseModel): + search_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/models/product.py b/src/stapi_fastapi/models/product.py index d81e79f..b17a4bd 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,46 +56,88 @@ class Product(BaseModel): _opportunity_properties: type[OpportunityProperties] _order_parameters: type[OrderParameters] _create_order: CreateOrder - _search_opportunities: SearchOpportunities + _search_opportunities: SearchOpportunities | None + _search_opportunities_async: SearchOpportunitiesAsync | None + _get_opportunity_collection: GetOpportunityCollection | None 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 + 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 search_opportunities(self: Self) -> SearchOpportunities: + 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 constraints(self: Self) -> type[Constraints]: + 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 - def with_links(self: Self, links: list[Link] | None = None) -> Self: + @property + def supports_opportunity_search(self) -> bool: + return self._search_opportunities is not None + + @property + 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, 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 247a024..4bacd77 100644 --- a/src/stapi_fastapi/routers/product_router.py +++ b/src/stapi_fastapi/routers/product_router.py @@ -2,18 +2,29 @@ import logging import traceback -from typing import TYPE_CHECKING, Self +from typing import TYPE_CHECKING -from fastapi import APIRouter, HTTPException, Request, Response, status +from fastapi import ( + APIRouter, + Depends, + Header, + HTTPException, + Request, + Response, + status, +) +from fastapi.responses import JSONResponse 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, OpportunityPayload, + OpportunitySearchRecord, + Prefer, ) from stapi_fastapi.models.order import Order, OrderPayload from stapi_fastapi.models.product import Product @@ -27,6 +38,19 @@ logger = logging.getLogger(__name__) +def get_prefer(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(prefer) + + class ProductRouter(APIRouter): def __init__( self, @@ -36,6 +60,16 @@ def __init__( **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,78 +129,153 @@ async def _create_order( tags=["Products"], ) + if ( + product.supports_opportunity_search + or root_router.supports_async_opportunity_search + ): + self.add_api_route( + path="/opportunities", + endpoint=self.search_opportunities, + name=f"{self.root_router.name}:{self.product.id}:search-opportunities", + 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 + ], + 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/{opportunity_collection_id}", + 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"], + ) + def get_product(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, ), - ], - ) + ) + + return self.product.with_links(links=links) async def search_opportunities( self, search: OpportunityPayload, request: Request, - ) -> OpportunityCollection: + response: Response, + prefer: Prefer | None = Depends(get_prefer), + ) -> OpportunityCollection | Response: """ Explore the opportunities available for a particular set of constraints """ + # sync + if not self.root_router.supports_async_opportunity_search or ( + prefer is Prefer.wait and self.product.supports_opportunity_search + ): + return await self.search_opportunities_sync( + search, + request, + response, + prefer, + ) + + # async + if ( + prefer is None + or prefer is Prefer.respond_async + or (prefer is Prefer.wait and not self.product.supports_opportunity_search) + ): + return await self.search_opportunities_async(search, request, prefer) + + raise AssertionError("Expected code to be unreachable") + + async def search_opportunities_sync( + 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, 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): @@ -195,15 +289,59 @@ async def search_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) - def get_product_constraints(self: Self) -> JsonSchemaModel: + async def search_opportunities_async( + self, + search: OpportunityPayload, + request: Request, + prefer: Prefer | None, + ) -> JSONResponse: + match await self.product.search_opportunities_async(self, search, request): + case Success(search_record): + search_record.links.append( + self.root_router.opportunity_search_record_self_link( + search_record, request + ) + ) + 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) -> 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 """ @@ -264,3 +402,43 @@ def pagination_link( method="POST", body=body, ) + + async def get_opportunity_collection( + 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( + 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 opportunity_collection + 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 2e68c84..af0571c 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -1,16 +1,29 @@ import logging import traceback -from typing import Self from fastapi import APIRouter, HTTPException, Request, status from fastapi.datastructures import URL 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, @@ -31,6 +44,8 @@ def __init__( 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 +54,22 @@ 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 + 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 self.docs_endpoint_name = docs_endpoint_name self.product_ids: list[str] = [] @@ -104,45 +130,77 @@ def __init__( tags=["Orders"], ) + 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, 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, - ), + links = [ + Link( + href=str(request.url_for(f"{self.name}:root")), + 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", + 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_GEOJSON, + ), + ] + + if self.supports_async_opportunity_search: + links.append( 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) -> Conformance: return Conformance(conforms_to=self.conformances) def get_products( @@ -154,7 +212,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 @@ -205,7 +263,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`. """ @@ -229,7 +287,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, @@ -237,12 +295,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))): - links.append(self.order_statuses_link(request, order_id)) - links.append(self.pagination_link(request, pagination_token, limit)) - case Success((statuses, Nothing)): # noqa: F841 + case Success(Some((statuses, maybe_pagination_token))): 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( @@ -257,19 +319,17 @@ 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, order: Order, request: Request) -> list[Link]: @@ -306,3 +366,105 @@ def pagination_link(self, request: Request, pagination_token: str, limit: int): rel="next", type=TYPE_JSON, ) + + async def get_opportunity_search_records( + self, request: Request, next: str | None = None, limit: int = 10 + ) -> OpportunitySearchRecords: + links: list[Link] = [] + match await self._get_opportunity_search_records(next, limit, request): + 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): + 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, 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(Some(search_record)): + 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") + 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, 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 opportunity_search_record_self_link( + self, opportunity_search_record: OpportunitySearchRecord, request: Request + ) -> Link: + return Link( + href=str( + self.generate_opportunity_search_record_href( + request, opportunity_search_record.id + ) + ), + rel="self", + type=TYPE_JSON, + ) + + @property + 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 self._get_opportunity_search_records is not None + and self._get_opportunity_search_record is not None + ) diff --git a/tests/application.py b/tests/application.py index d832cb6..40c9fdb 100644 --- a/tests/application.py +++ b/tests/application.py @@ -8,17 +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 ( + InMemoryOpportunityDB, InMemoryOrderDB, - mock_product_test_satellite_provider, - mock_product_test_spotlight, + product_test_satellite_provider_sync_opportunity, + product_test_spotlight_sync_async_opportunity, ) @@ -27,6 +30,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: try: yield { "_orders_db": InMemoryOrderDB(), + "_opportunities_db": InMemoryOpportunityDB(), } finally: pass @@ -36,9 +40,11 @@ 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(mock_product_test_satellite_provider) +root_router.add_product(product_test_spotlight_sync_async_opportunity) +root_router.add_product(product_test_satellite_provider_sync_opportunity) app: FastAPI = FastAPI(lifespan=lifespan) app.include_router(root_router, prefix="") diff --git a/tests/backends.py b/tests/backends.py index 3f6cdef..d34a08d 100644 --- a/tests/backends.py +++ b/tests/backends.py @@ -7,7 +7,11 @@ from stapi_fastapi.models.opportunity import ( Opportunity, + OpportunityCollection, OpportunityPayload, + OpportunitySearchRecord, + OpportunitySearchStatus, + OpportunitySearchStatusCode, ) from stapi_fastapi.models.order import ( Order, @@ -35,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( @@ -50,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) @@ -68,32 +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)) - except Exception as e: - return Failure(e) - - -async def mock_search_opportunities( - product_router: ProductRouter, - search: OpportunityPayload, - 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)) + return Success(Some((stati, Some(str(end))))) + return Success(Some((stati, Nothing))) except Exception as e: return Failure(e) @@ -130,8 +116,105 @@ 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) + + +async def mock_search_opportunities( + product_router: ProductRouter, + search: OpportunityPayload, + 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: OpportunityPayload, + request: Request, +) -> ResultE[OpportunitySearchRecord]: + try: + received_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=received_status, + links=[], + ) + request.state._opportunities_db.put_search_record(search_record) + 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.get_opportunity_collection( + 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 = request.state._opportunities_db.get_search_records() + + 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.get_search_record(search_record_id) + ) + ) + except Exception as e: + return Failure(e) diff --git a/tests/conftest.py b/tests/conftest.py index c060495..d9b80a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ -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 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") @@ -35,8 +41,13 @@ def base_url() -> Iterator[str]: @pytest.fixture -def mock_products() -> list[Product]: - return [mock_product_test_spotlight, mock_product_test_satellite_provider] +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, + ] @pytest.fixture @@ -49,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: @@ -64,9 +75,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 +89,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], +) -> Generator[TestClient, None, None]: + @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 +150,33 @@ 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 { + "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 b4c382e..9ad7877 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 urllib.parse import parse_qs, urlparse @@ -14,7 +15,9 @@ from stapi_fastapi.models.opportunity import ( Opportunity, + OpportunityCollection, OpportunityProperties, + OpportunitySearchRecord, ) from stapi_fastapi.models.order import ( Order, @@ -29,7 +32,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 +49,44 @@ 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: + self._search_records: dict[str, OpportunitySearchRecord] = {} + 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 @@ -77,7 +120,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", @@ -87,12 +182,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", @@ -102,6 +199,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, @@ -191,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_opportunity.py b/tests/test_opportunity.py index 202dabf..e22a614 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,40 +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 = { - "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() @@ -61,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 @@ -71,29 +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 = { - "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, url=f"/products/{product_id}/opportunities", @@ -101,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..a3bba17 --- /dev/null +++ b/tests/test_opportunity_async.py @@ -0,0 +1,354 @@ +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 ( + OpportunityCollection, + OpportunitySearchRecord, + OpportunitySearchStatus, + OpportunitySearchStatusCode, +) +from stapi_fastapi.models.shared import Link + +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.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 + 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.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 + 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) -> None: + product_id = "test-spotlight" + + # 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") + + +@pytest.mark.mock_products([product_test_spotlight_async_opportunity]) +def test_async_search_response( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> 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 + + body = response.json() + try: + _ = OpportunitySearchRecord(**body) + except Exception as _: + pytest.fail("response is not an opportunity search record") + + assert find_link(body["links"], "self") + + +@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], +) -> 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 + + body = response.json() + try: + _ = OpportunitySearchRecord(**body) + except Exception as _: + pytest.fail("response is not an opportunity search record") + + +@pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) +def test_prefer_header( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], +) -> 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 + 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 + 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") + + +@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], +) -> 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.mock_products([product_test_spotlight_async_opportunity]) +def test_async_opportunity_search_to_completion( + stapi_client_async_opportunity: TestClient, + opportunity_search: dict[str, Any], + 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; verify 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") + + +@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], +) -> 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 + + search_record = search_response.json() + link = find_link(search_record["links"], "self") + assert link + assert search_response.headers["Location"] == str(link["href"]) + + +@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( + 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, +) -> 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.mock_products([product_test_spotlight_async_opportunity]) +def test_get_search_records_pagination( + stapi_client_async_opportunity: TestClient, + 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, + ) 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 = [] diff --git a/tests/test_product.py b/tests/test_product.py index 26b7bd4..20c3316 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.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 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")