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

Commit d2b849a

Browse files
committed
Merge branch 'main' into pjh/asynchronous-opportunity-search
2 parents 3db2a60 + f3adb87 commit d2b849a

File tree

12 files changed

+106
-95
lines changed

12 files changed

+106
-95
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
2121
token.
2222
- Moved `OrderCollection` construction from the root backend to the `RootRouter`
2323
`get_orders` method.
24+
- Renamed `OpportunityRequest` to `OpportunityPayload` so that would not be confused as
25+
being a subclass of the Starlette/FastAPI Request class.
26+
27+
### Fixed
28+
29+
- Opportunities Search result now has the search body in the `create-order` link.
2430

2531
## [v0.5.0] - 2025-01-08
2632

src/stapi_fastapi/backends/product_backend.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@
99
from stapi_fastapi.models.opportunity import (
1010
Opportunity,
1111
OpportunityCollection,
12-
OpportunityRequest,
12+
OpportunityPayload,
1313
OpportunitySearchRecord,
1414
)
1515
from stapi_fastapi.models.order import Order, OrderPayload
1616
from stapi_fastapi.routers.product_router import ProductRouter
1717

1818
SearchOpportunities = Callable[
19-
[ProductRouter, OpportunityRequest, str | None, int, Request],
19+
[ProductRouter, OpportunityPayload, str | None, int, Request],
2020
Coroutine[Any, Any, ResultE[tuple[list[Opportunity], Maybe[str]]]],
2121
]
2222
"""
@@ -25,7 +25,7 @@
2525
2626
Args:
2727
product_router (ProductRouter): The product router.
28-
search (OpportunityRequest): The search parameters.
28+
search (OpportunityPayload): The search parameters.
2929
next (str | None): A pagination token.
3030
limit (int): The maximum number of opportunities to return in a page.
3131
request (Request): FastAPI's Request object.
@@ -43,7 +43,7 @@
4343
"""
4444

4545
SearchOpportunitiesAsync = Callable[
46-
[ProductRouter, OpportunityRequest, Request],
46+
[ProductRouter, OpportunityPayload, Request],
4747
Coroutine[Any, Any, ResultE[OpportunitySearchRecord]],
4848
]
4949
"""
@@ -52,7 +52,7 @@
5252
5353
Args:
5454
product_router (ProductRouter): The product router.
55-
search (OpportunityRequest): The search parameters.
55+
search (OpportunityPayload): The search parameters.
5656
request (Request): FastAPI's Request object.
5757
5858
Returns:

src/stapi_fastapi/models/opportunity.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from enum import StrEnum
2-
from typing import Literal, TypeVar
2+
from typing import Any, Literal, TypeVar
33

44
from geojson_pydantic import Feature, FeatureCollection
55
from geojson_pydantic.geometries import Geometry
@@ -17,13 +17,22 @@ class OpportunityProperties(BaseModel):
1717
model_config = ConfigDict(extra="allow")
1818

1919

20-
class OpportunityRequest(BaseModel):
20+
class OpportunityPayload(BaseModel):
2121
datetime: DatetimeInterval
2222
geometry: Geometry
23-
# TODO: validate the CQL2 filter?
2423
filter: CQL2Filter | None = None
24+
25+
next: str | None = None
26+
limit: int = 10
27+
2528
model_config = ConfigDict(strict=True)
2629

30+
def search_body(self) -> dict[str, Any]:
31+
return self.model_dump(mode="json", include={"datetime", "geometry", "filter"})
32+
33+
def body(self) -> dict[str, Any]:
34+
return self.model_dump(mode="json")
35+
2736

2837
G = TypeVar("G", bound=Geometry)
2938
P = TypeVar("P", bound=OpportunityProperties)
@@ -59,7 +68,7 @@ class OpportunitySearchStatus(BaseModel):
5968
class OpportunitySearchRecord(BaseModel):
6069
id: str
6170
product_id: str
62-
opportunity_request: OpportunityRequest
71+
opportunity_request: OpportunityPayload
6372
status: OpportunitySearchStatus
6473
links: list[Link] = Field(default_factory=list)
6574

src/stapi_fastapi/routers/product_router.py

Lines changed: 19 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@
22

33
import logging
44
import traceback
5-
from typing import TYPE_CHECKING, Annotated, Self
5+
from typing import TYPE_CHECKING, Self
66

77
from fastapi import (
88
APIRouter,
9-
Body,
109
Depends,
1110
Header,
1211
HTTPException,
@@ -23,7 +22,7 @@
2322
from stapi_fastapi.exceptions import ConstraintsException, NotFoundException
2423
from stapi_fastapi.models.opportunity import (
2524
OpportunityCollection,
26-
OpportunityRequest,
25+
OpportunityPayload,
2726
OpportunitySearchRecord,
2827
Prefer,
2928
)
@@ -225,11 +224,9 @@ def get_product(self: Self, request: Request) -> Product:
225224

226225
async def search_opportunities(
227226
self: Self,
228-
search: OpportunityRequest,
227+
search: OpportunityPayload,
229228
request: Request,
230229
response: Response,
231-
next: Annotated[str | None, Body()] = None,
232-
limit: Annotated[int, Body()] = 10,
233230
prefer: Prefer | None = Depends(get_preference),
234231
) -> OpportunityCollection | Response:
235232
"""
@@ -244,8 +241,6 @@ async def search_opportunities(
244241
request,
245242
response,
246243
prefer,
247-
next,
248-
limit,
249244
)
250245

251246
# async
@@ -260,31 +255,24 @@ async def search_opportunities(
260255

261256
async def search_opportunities_sync(
262257
self: Self,
263-
search: OpportunityRequest,
258+
search: OpportunityPayload,
264259
request: Request,
265260
response: Response,
266261
prefer: Prefer | None,
267-
next: Annotated[str | None, Body()] = None,
268-
limit: Annotated[int, Body()] = 10,
269262
) -> OpportunityCollection:
270263
links: list[Link] = []
271264
match await self.product._search_opportunities(
272265
self,
273266
search,
274-
next,
275-
limit,
267+
search.next,
268+
search.limit,
276269
request,
277270
):
278271
case Success((features, Some(pagination_token))):
279-
links.append(self.order_link(request))
280-
body = {
281-
"search": search.model_dump(mode="json"),
282-
"next": pagination_token,
283-
"limit": limit,
284-
}
285-
links.append(self.pagination_link(request, body))
272+
links.append(self.order_link(request, search))
273+
links.append(self.pagination_link(request, search, pagination_token))
286274
case Success((features, Nothing)): # noqa: F841
287-
links.append(self.order_link(request))
275+
links.append(self.order_link(request, search))
288276
case Failure(e) if isinstance(e, ConstraintsException):
289277
raise e
290278
case Failure(e):
@@ -306,7 +294,7 @@ async def search_opportunities_sync(
306294

307295
async def search_opportunities_async(
308296
self: Self,
309-
search: OpportunityRequest,
297+
search: OpportunityPayload,
310298
request: Request,
311299
prefer: Prefer | None,
312300
) -> JSONResponse:
@@ -366,7 +354,7 @@ async def create_order(
366354
request,
367355
):
368356
case Success(order):
369-
self.root_router.add_order_links(order, request)
357+
order.links.extend(self.root_router.order_links(order, request))
370358
location = str(self.root_router.generate_order_href(request, order.id))
371359
response.headers["Location"] = location
372360
return order
@@ -384,7 +372,7 @@ async def create_order(
384372
case x:
385373
raise AssertionError(f"Expected code to be unreachable {x}")
386374

387-
def order_link(self, request: Request):
375+
def order_link(self, request: Request, opp_req: OpportunityPayload):
388376
return Link(
389377
href=str(
390378
request.url_for(
@@ -394,11 +382,16 @@ def order_link(self, request: Request):
394382
rel="create-order",
395383
type=TYPE_JSON,
396384
method="POST",
385+
body=opp_req.search_body(),
397386
)
398387

399-
def pagination_link(self, request: Request, body: dict[str, str | dict]):
388+
def pagination_link(
389+
self, request: Request, opp_req: OpportunityPayload, pagination_token: str
390+
):
391+
body = opp_req.body()
392+
body["next"] = pagination_token
400393
return Link(
401-
href=str(request.url.remove_query_params(keys=["next", "limit"])),
394+
href=str(request.url),
402395
rel="next",
403396
type=TYPE_JSON,
404397
method="POST",

src/stapi_fastapi/routers/root_router.py

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ def get_products(
230230
),
231231
]
232232
if end > 0 and end < len(self.product_ids):
233-
links.append(self.pagination_link(request, self.product_ids[end]))
233+
links.append(self.pagination_link(request, self.product_ids[end], limit))
234234
return ProductsCollection(
235235
products=[
236236
self.product_routers[product_id].get_product(request)
@@ -244,13 +244,14 @@ async def get_orders(
244244
) -> OrderCollection:
245245
links: list[Link] = []
246246
match await self._get_orders(next, limit, request):
247-
case Success((orders, Some(pagination_token))):
247+
case Success((orders, maybe_pagination_token)):
248248
for order in orders:
249-
order.links.append(self.order_link(request, order))
250-
links.append(self.pagination_link(request, pagination_token))
251-
case Success((orders, Nothing)): # noqa: F841
252-
for order in orders:
253-
order.links.append(self.order_link(request, order))
249+
order.links.extend(self.order_links(order, request))
250+
match maybe_pagination_token:
251+
case Some(x):
252+
links.append(self.pagination_link(request, x, limit))
253+
case Maybe.empty:
254+
pass
254255
case Failure(ValueError()):
255256
raise NotFoundException(detail="Error finding pagination token")
256257
case Failure(e):
@@ -272,7 +273,7 @@ async def get_order(self: Self, order_id: str, request: Request) -> Order:
272273
"""
273274
match await self._get_order(order_id, request):
274275
case Success(Some(order)):
275-
self.add_order_links(order, request)
276+
order.links.extend(self.order_links(order, request))
276277
return order
277278
case Success(Maybe.empty):
278279
raise NotFoundException("Order not found")
@@ -300,7 +301,7 @@ async def get_order_statuses(
300301
match await self._get_order_statuses(order_id, next, limit, request):
301302
case Success((statuses, Some(pagination_token))):
302303
links.append(self.order_statuses_link(request, order_id))
303-
links.append(self.pagination_link(request, pagination_token))
304+
links.append(self.pagination_link(request, pagination_token, limit))
304305
case Success((statuses, Nothing)): # noqa: F841
305306
links.append(self.order_statuses_link(request, order_id))
306307
case Failure(KeyError()):
@@ -333,28 +334,19 @@ def generate_order_statuses_href(
333334
) -> URL:
334335
return request.url_for(f"{self.name}:list-order-statuses", order_id=order_id)
335336

336-
def add_order_links(self: Self, order: Order, request: Request):
337-
order.links.append(
337+
def order_links(self: Self, order: Order, request: Request) -> list[Link]:
338+
return [
338339
Link(
339340
href=str(self.generate_order_href(request, order.id)),
340341
rel="self",
341342
type=TYPE_GEOJSON,
342-
)
343-
)
344-
order.links.append(
343+
),
345344
Link(
346345
href=str(self.generate_order_statuses_href(request, order.id)),
347346
rel="monitor",
348347
type=TYPE_JSON,
349348
),
350-
)
351-
352-
def order_link(self, request: Request, order: Order):
353-
return Link(
354-
href=str(request.url_for(f"{self.name}:get-order", order_id=order.id)),
355-
rel="self",
356-
type=TYPE_JSON,
357-
)
349+
]
358350

359351
def order_statuses_link(self, request: Request, order_id: str):
360352
return Link(
@@ -368,9 +360,11 @@ def order_statuses_link(self, request: Request, order_id: str):
368360
type=TYPE_JSON,
369361
)
370362

371-
def pagination_link(self, request: Request, pagination_token: str):
363+
def pagination_link(self, request: Request, pagination_token: str, limit: int):
372364
return Link(
373-
href=str(request.url.include_query_params(next=pagination_token)),
365+
href=str(
366+
request.url.include_query_params(next=pagination_token, limit=limit)
367+
),
374368
rel="next",
375369
type=TYPE_JSON,
376370
)

tests/application.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from tests.shared import (
2121
InMemoryOpportunityDB,
2222
InMemoryOrderDB,
23+
product_test_satellite_provider_sync_opportunity,
2324
product_test_spotlight_sync_async_opportunity,
2425
)
2526

@@ -44,5 +45,6 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]:
4445
conformances=[CORE, OPPORTUNITIES, ASYNC_OPPORTUNITIES],
4546
)
4647
root_router.add_product(product_test_spotlight_sync_async_opportunity)
48+
root_router.add_product(product_test_satellite_provider_sync_opportunity)
4749
app: FastAPI = FastAPI(lifespan=lifespan)
4850
app.include_router(root_router, prefix="")

tests/backends.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from stapi_fastapi.models.opportunity import (
99
Opportunity,
1010
OpportunityCollection,
11-
OpportunityRequest,
11+
OpportunityPayload,
1212
OpportunitySearchRecord,
1313
OpportunitySearchStatus,
1414
OpportunitySearchStatusCode,
@@ -119,7 +119,7 @@ async def mock_create_order(
119119

120120
async def mock_search_opportunities(
121121
product_router: ProductRouter,
122-
search: OpportunityRequest,
122+
search: OpportunityPayload,
123123
next: str | None,
124124
limit: int,
125125
request: Request,
@@ -143,7 +143,7 @@ async def mock_search_opportunities(
143143

144144
async def mock_search_opportunities_async(
145145
product_router: ProductRouter,
146-
search: OpportunityRequest,
146+
search: OpportunityPayload,
147147
request: Request,
148148
) -> ResultE[OpportunitySearchRecord]:
149149
try:

tests/conftest.py

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -164,19 +164,17 @@ def opportunity_search(limit) -> dict[str, Any]:
164164
end_string = rfc3339_strftime(end, format)
165165

166166
return {
167-
"search": {
168-
"geometry": {
169-
"type": "Point",
170-
"coordinates": [0, 0],
171-
},
172-
"datetime": f"{start_string}/{end_string}",
173-
"filter": {
174-
"op": "and",
175-
"args": [
176-
{"op": ">", "args": [{"property": "off_nadir"}, 0]},
177-
{"op": "<", "args": [{"property": "off_nadir"}, 45]},
178-
],
179-
},
167+
"geometry": {
168+
"type": "Point",
169+
"coordinates": [0, 0],
170+
},
171+
"datetime": f"{start_string}/{end_string}",
172+
"filter": {
173+
"op": "and",
174+
"args": [
175+
{"op": ">", "args": [{"property": "off_nadir"}, 0]},
176+
{"op": "<", "args": [{"property": "off_nadir"}, 45]},
177+
],
180178
},
181179
"limit": limit,
182180
}

0 commit comments

Comments
 (0)