Skip to content
This repository was archived by the owner on Apr 2, 2025. It is now read-only.

Commit 7a00095

Browse files
committed
feat: Add async and optional opportunity search to routers
1 parent dd7c7ad commit 7a00095

File tree

3 files changed

+399
-93
lines changed

3 files changed

+399
-93
lines changed

src/stapi_fastapi/models/opportunity.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ class OpportunitySearchRecord(BaseModel):
6464
links: list[Link] = Field(default_factory=list)
6565

6666

67+
class OpportunitySearchRecords(BaseModel):
68+
records: list[OpportunitySearchRecord]
69+
links: list[Link] = Field(default_factory=list)
70+
71+
6772
class Prefer(StrEnum):
6873
respond_async = "respond-async"
6974
wait = "wait"

src/stapi_fastapi/routers/product_router.py

Lines changed: 208 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,27 @@
44
import traceback
55
from typing import TYPE_CHECKING, Annotated, Self
66

7-
from fastapi import APIRouter, Body, HTTPException, Request, Response, status
7+
from fastapi import (
8+
APIRouter,
9+
Body,
10+
Depends,
11+
Header,
12+
HTTPException,
13+
Request,
14+
Response,
15+
status,
16+
)
817
from geojson_pydantic.geometries import Geometry
9-
from returns.maybe import Some
18+
from returns.maybe import Maybe, Some
1019
from returns.result import Failure, Success
1120

1221
from stapi_fastapi.constants import TYPE_JSON
13-
from stapi_fastapi.exceptions import ConstraintsException
22+
from stapi_fastapi.exceptions import ConstraintsException, NotFoundException
1423
from stapi_fastapi.models.opportunity import (
1524
OpportunityCollection,
1625
OpportunityRequest,
26+
OpportunitySearchRecord,
27+
Prefer,
1728
)
1829
from stapi_fastapi.models.order import Order, OrderPayload
1930
from stapi_fastapi.models.product import Product
@@ -27,15 +38,38 @@
2738
logger = logging.getLogger(__name__)
2839

2940

41+
def get_preference(prefer: str | None = Header(None)) -> str | None:
42+
if prefer is None:
43+
return None
44+
45+
if prefer not in Prefer:
46+
raise HTTPException(
47+
status_code=status.HTTP_400_BAD_REQUEST,
48+
detail=f"Invalid Prefer header value: {prefer}",
49+
)
50+
51+
return prefer
52+
53+
3054
class ProductRouter(APIRouter):
3155
def __init__(
32-
self,
56+
self: Self,
3357
product: Product,
3458
root_router: RootRouter,
3559
*args,
3660
**kwargs,
3761
) -> None:
3862
super().__init__(*args, **kwargs)
63+
64+
if (
65+
root_router.supports_async_opportunity_search
66+
and not product.supports_async_opportunity_search
67+
):
68+
raise ValueError(
69+
f"Product '{product.id}' must support async opportunity search since "
70+
f"the root router does",
71+
)
72+
3973
self.product = product
4074
self.root_router = root_router
4175

@@ -48,21 +82,6 @@ def __init__(
4882
tags=["Products"],
4983
)
5084

51-
self.add_api_route(
52-
path="/opportunities",
53-
endpoint=self.search_opportunities,
54-
name=f"{self.root_router.name}:{self.product.id}:search-opportunities",
55-
methods=["POST"],
56-
response_class=GeoJSONResponse,
57-
# unknown why mypy can't see the constraints property on Product, ignoring
58-
response_model=OpportunityCollection[
59-
Geometry,
60-
self.product.opportunity_properties, # type: ignore
61-
],
62-
summary="Search Opportunities for the product",
63-
tags=["Products"],
64-
)
65-
6685
self.add_api_route(
6786
path="/constraints",
6887
endpoint=self.get_product_constraints,
@@ -110,7 +129,43 @@ async def _create_order(
110129
tags=["Products"],
111130
)
112131

113-
def get_product(self, request: Request) -> Product:
132+
if product.supports_opportunity_search:
133+
self.add_api_route(
134+
path="/opportunities",
135+
endpoint=self.search_opportunities,
136+
name=f"{self.root_router.name}:{self.product.id}:search-opportunities",
137+
methods=["POST"],
138+
response_class=GeoJSONResponse,
139+
# unknown why mypy can't see the constraints property on Product, ignoring
140+
response_model=OpportunityCollection[
141+
Geometry,
142+
self.product.opportunity_properties, # type: ignore
143+
],
144+
summary="Search Opportunities for the product",
145+
tags=["Products"],
146+
)
147+
148+
if root_router.supports_async_opportunity_search:
149+
self.add_api_route(
150+
path="/opportunities",
151+
endpoint=self.search_opportunities_async,
152+
name=f"{self.root_router.name}:{self.product.id}:search-opportunities",
153+
methods=["POST"],
154+
status_code=status.HTTP_201_CREATED,
155+
summary="Search Opportunities for the product",
156+
tags=["Products"],
157+
)
158+
159+
self.add_api_route(
160+
path="/opportunities/{opportunity_collection_id}",
161+
endpoint=self.get_opportunity_collection,
162+
name=f"{self.root_router.name}:{self.product.id}:get-opportunity-collection",
163+
methods=["GET"],
164+
summary="Get an Opportunity Collection by ID",
165+
tags=["Products"],
166+
)
167+
168+
def get_product(self: Self, request: Request) -> Product:
114169
return self.product.with_links(
115170
links=[
116171
Link(
@@ -162,47 +217,108 @@ def get_product(self, request: Request) -> Product:
162217
)
163218

164219
async def search_opportunities(
165-
self,
220+
self: Self,
166221
search: OpportunityRequest,
167222
request: Request,
223+
response: GeoJSONResponse,
224+
prefer: str | None = Depends(get_preference),
168225
next: Annotated[str | None, Body()] = None,
169226
limit: Annotated[int, Body()] = 10,
170227
) -> OpportunityCollection:
171228
"""
172229
Explore the opportunities available for a particular set of constraints
173230
"""
174-
links: list[Link] = []
175-
match await self.product._search_opportunities(
176-
self,
177-
search,
178-
next,
179-
limit,
180-
request,
231+
if (
232+
not self.root_router.supports_async_opportunity_search
233+
or prefer is Prefer.wait
181234
):
182-
case Success((features, Some(pagination_token))):
183-
links.append(self.order_link(request))
184-
body = {
185-
"search": search.model_dump(mode="json"),
186-
"next": pagination_token,
187-
"limit": limit,
188-
}
189-
links.append(self.pagination_link(request, body))
190-
case Success((features, Nothing)): # noqa: F841
191-
links.append(self.order_link(request))
192-
case Failure(e) if isinstance(e, ConstraintsException):
193-
raise e
194-
case Failure(e):
195-
logger.error(
196-
"An error occurred while searching opportunities: %s",
197-
traceback.format_exception(e),
198-
)
199-
raise HTTPException(
200-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
201-
detail="Error searching opportunities",
202-
)
203-
case x:
204-
raise AssertionError(f"Expected code to be unreachable {x}")
205-
return OpportunityCollection(features=features, links=links)
235+
links: list[Link] = []
236+
match await self.product._search_opportunities(
237+
self,
238+
search,
239+
next,
240+
limit,
241+
request,
242+
):
243+
case Success((features, Some(pagination_token))):
244+
links.append(self.order_link(request))
245+
body = {
246+
"search": search.model_dump(mode="json"),
247+
"next": pagination_token,
248+
"limit": limit,
249+
}
250+
links.append(self.pagination_link(request, body))
251+
case Success((features, Nothing)): # noqa: F841
252+
links.append(self.order_link(request))
253+
case Failure(e) if isinstance(e, ConstraintsException):
254+
raise e
255+
case Failure(e):
256+
logger.error(
257+
"An error occurred while searching opportunities: %s",
258+
traceback.format_exception(e),
259+
)
260+
raise HTTPException(
261+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
262+
detail="Error searching opportunities",
263+
)
264+
case x:
265+
raise AssertionError(f"Expected code to be unreachable {x}")
266+
267+
if (
268+
prefer is Prefer.wait
269+
and self.root_router.supports_async_opportunity_search
270+
):
271+
response.headers["Preference-Applied"] = "wait"
272+
273+
return OpportunityCollection(features=features, links=links)
274+
275+
raise AssertionError("Expected code to be unreachable")
276+
277+
async def search_opportunities_async(
278+
self: Self,
279+
search: OpportunityRequest,
280+
request: Request,
281+
response: Response,
282+
prefer: str | None = Depends(get_preference),
283+
) -> OpportunitySearchRecord:
284+
"""
285+
Initiate an asynchronous search for opportunities.
286+
287+
TODO: Do I need a location header somewhere?
288+
"""
289+
if (
290+
prefer is None
291+
or prefer is Prefer.respond_async
292+
or (prefer is Prefer.wait and not self.product.supports_opportunity_search)
293+
):
294+
match await self.product._search_opportunities_async(self, search, request):
295+
case Success(search_record):
296+
self.root_router.add_opportunity_search_record_self_link(
297+
search_record, request
298+
)
299+
response.headers["Location"] = str(
300+
self.root_router.generate_opportunity_search_record_href(
301+
request, search_record.id
302+
)
303+
)
304+
if prefer is not None:
305+
response.headers["Preference-Applied"] = "respond-async"
306+
return search_record
307+
case Failure(e) if isinstance(e, ConstraintsException):
308+
raise e
309+
case Failure(e):
310+
logger.error(
311+
"An error occurred while initiating an asynchronous opportunity search: %s",
312+
traceback.format_exception(e),
313+
)
314+
raise HTTPException(
315+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
316+
detail="Error initiating an asynchronous opportunity search",
317+
)
318+
case x:
319+
raise AssertionError(f"Expected code to be unreachable: {x}")
320+
321+
raise AssertionError("Expected code to be unreachable")
206322

207323
def get_product_constraints(self: Self) -> JsonSchemaModel:
208324
"""
@@ -266,3 +382,43 @@ def pagination_link(self, request: Request, body: dict[str, str | dict]):
266382
method="POST",
267383
body=body,
268384
)
385+
386+
async def get_opportunity_collection(
387+
self: Self, opportunity_collection_id: str, request: Request
388+
) -> Response:
389+
"""
390+
Fetch an opportunity collection generated by an asynchronous opportunity search.
391+
"""
392+
match await self.product._get_opportunity_collection(
393+
self,
394+
opportunity_collection_id,
395+
request,
396+
):
397+
case Success(Some(opportunity_collection)):
398+
opportunity_collection.links.append(
399+
Link(
400+
href=str(
401+
request.url_for(
402+
f"{self.root_router.name}:{self.product.id}:get-opportunity-collection",
403+
opportunity_collection_id=opportunity_collection_id,
404+
),
405+
),
406+
rel="self",
407+
type=TYPE_JSON,
408+
),
409+
)
410+
return GeoJSONResponse(content=opportunity_collection.model_dump_json())
411+
case Success(Maybe.empty):
412+
raise NotFoundException("Opportunity Collection not found")
413+
case Failure(e):
414+
logger.error(
415+
"An error occurred while fetching opportunity collection: '%s': %s",
416+
opportunity_collection_id,
417+
traceback.format_exception(e),
418+
)
419+
raise HTTPException(
420+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
421+
detail="Error fetching Opportunity Collection",
422+
)
423+
case x:
424+
raise AssertionError(f"Expected code to be unreachable {x}")

0 commit comments

Comments
 (0)