44import traceback
55from 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+ )
817from geojson_pydantic .geometries import Geometry
9- from returns .maybe import Some
18+ from returns .maybe import Maybe , Some
1019from returns .result import Failure , Success
1120
1221from stapi_fastapi .constants import TYPE_JSON
13- from stapi_fastapi .exceptions import ConstraintsException
22+ from stapi_fastapi .exceptions import ConstraintsException , NotFoundException
1423from stapi_fastapi .models .opportunity import (
1524 OpportunityCollection ,
1625 OpportunityRequest ,
26+ OpportunitySearchRecord ,
27+ Prefer ,
1728)
1829from stapi_fastapi .models .order import Order , OrderPayload
1930from stapi_fastapi .models .product import Product
2738logger = 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+
3054class 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