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

Commit 78ac040

Browse files
authored
Add asynchronous and optional opportunity searching (#136)
* feat: add async opportunity models * feat: update backends for asynchronous opportunity searches * feat: update product model to support async and optional opportunity searching * feat: Add async and optional opportunity search to routers * fix: revert back to a clunky, but functional, opportunity search endpoint * tests: add tests for async opportunity searching * tests: add more async tests and fix a few bugs * tests: add pagination tests for async search, fix a few bugs * tests: Add a custom marker for supplying mock products * docs: update CHANGELOG * fix: fix db mutation bug, sundry fixes * review: class attributes always defined, remove implicit Self type * review: Rename get_preference to get_prefer * review: sidecar fix for logging -> logger * review: logging.error -> logging.exception * review: correct list-orders type to geo+json * review: sidecar - remove unused STAPI_VERSION * review: Use Generator instead of Iterator * review: correct logger.exception/logger.error use * review: simplify root router links * fix: minor typo fixes in CHANGELOG
1 parent 33a8e04 commit 78ac040

21 files changed

+1340
-260
lines changed

CHANGELOG.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1111

1212
- Added token-based pagination to `GET /orders`, `GET /products`,
1313
`GET /orders/{order_id}/statuses`, and `POST /products/{product_id}/opportunities`.
14-
- Optional and Extension STAPI Status Codes "scheduled", "held", "processing", "reserved", "tasked",
15-
and "user_cancelled"
14+
- Optional and Extension STAPI Status Codes "scheduled", "held", "processing",
15+
"reserved", "tasked", and "user_cancelled"
16+
- Asynchronous opportunity search. If the root router supports asynchronous opportunity
17+
search, all products must support it. If asynchronous opportunity search is
18+
supported, `POST` requests to the `/products/{productId}/opportunities` endpoint will
19+
default to asynchronous opportunity search unless synchronous search is also supported
20+
by the `product` and a `Prefer` header in the `POST` request is set to `wait`.
21+
- Added the `/products/{productId}/opportunities/` and `/searches/opportunities`
22+
endpoints to support asynchronous opportunity search.
1623

1724
### Changed
1825

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ filterwarnings = [
6969
"ignore:The 'app' shortcut is now deprecated.:DeprecationWarning",
7070
"ignore:Pydantic serializer warnings:UserWarning",
7171
]
72+
markers = [
73+
"mock_products",
74+
]
7275

7376
[build-system]
7477
requires = [
Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
1-
from .product_backend import CreateOrder, SearchOpportunities
2-
from .root_backend import GetOrder, GetOrders, GetOrderStatuses
1+
from .product_backend import (
2+
CreateOrder,
3+
GetOpportunityCollection,
4+
SearchOpportunities,
5+
SearchOpportunitiesAsync,
6+
)
7+
from .root_backend import (
8+
GetOpportunitySearchRecord,
9+
GetOpportunitySearchRecords,
10+
GetOrder,
11+
GetOrders,
12+
GetOrderStatuses,
13+
)
314

415
__all__ = [
516
"CreateOrder",
17+
"GetOpportunityCollection",
18+
"GetOpportunitySearchRecord",
19+
"GetOpportunitySearchRecords",
620
"GetOrder",
721
"GetOrders",
822
"GetOrderStatuses",
923
"SearchOpportunities",
24+
"SearchOpportunitiesAsync",
1025
]

src/stapi_fastapi/backends/product_backend.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66
from returns.maybe import Maybe
77
from returns.result import ResultE
88

9-
from stapi_fastapi.models.opportunity import Opportunity, OpportunityPayload
9+
from stapi_fastapi.models.opportunity import (
10+
Opportunity,
11+
OpportunityCollection,
12+
OpportunityPayload,
13+
OpportunitySearchRecord,
14+
)
1015
from stapi_fastapi.models.order import Order, OrderPayload
1116
from stapi_fastapi.routers.product_router import ProductRouter
1217

@@ -20,7 +25,7 @@
2025
2126
Args:
2227
product_router (ProductRouter): The product router.
23-
search (OpportunityRequest): The search parameters.
28+
search (OpportunityPayload): The search parameters.
2429
next (str | None): A pagination token.
2530
limit (int): The maximum number of opportunities to return in a page.
2631
request (Request): FastAPI's Request object.
@@ -37,6 +42,48 @@
3742
returns.result.Failure[stapi_fastapi.exceptions.ConstraintsException] if not valid.
3843
"""
3944

45+
SearchOpportunitiesAsync = Callable[
46+
[ProductRouter, OpportunityPayload, Request],
47+
Coroutine[Any, Any, ResultE[OpportunitySearchRecord]],
48+
]
49+
"""
50+
Type alias for an async function that starts an asynchronous search for ordering
51+
opportunities for the given search parameters.
52+
53+
Args:
54+
product_router (ProductRouter): The product router.
55+
search (OpportunityPayload): The search parameters.
56+
request (Request): FastAPI's Request object.
57+
58+
Returns:
59+
- Should return returns.result.Success[OpportunitySearchRecord]
60+
- Returning returns.result.Failure[Exception] will result in a 500.
61+
62+
Backends must validate search constraints and return
63+
returns.result.Failure[stapi_fastapi.exceptions.ConstraintsException] if not valid.
64+
"""
65+
66+
GetOpportunityCollection = Callable[
67+
[ProductRouter, str, Request],
68+
Coroutine[Any, Any, ResultE[Maybe[OpportunityCollection]]],
69+
]
70+
"""
71+
Type alias for an async function that retrieves the opportunity collection with
72+
`opportunity_collection_id`.
73+
74+
The opportunity collection is generated by an asynchronous opportunity search.
75+
76+
Args:
77+
product_router (ProductRouter): The product router.
78+
opportunity_collection_id (str): The ID of the opportunity collection.
79+
request (Request): FastAPI's Request object.
80+
81+
Returns:
82+
- Should return returns.result.Success[returns.maybe.Some[OpportunityCollection]] if the opportunity collection is found.
83+
- Should return returns.result.Success[returns.maybe.Nothing] if the opportunity collection is not found or if access is denied.
84+
- Returning returns.result.Failure[Exception] will result in a 500.
85+
"""
86+
4087
CreateOrder = Callable[
4188
[ProductRouter, OrderPayload, Request], Coroutine[Any, Any, ResultE[Order]]
4289
]

src/stapi_fastapi/backends/root_backend.py

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from returns.maybe import Maybe
55
from returns.result import ResultE
66

7+
from stapi_fastapi.models.opportunity import OpportunitySearchRecord
78
from stapi_fastapi.models.order import (
89
Order,
910
OrderStatus,
@@ -39,8 +40,7 @@
3940
4041
Returns:
4142
- Should return returns.result.Success[returns.maybe.Some[Order]] if order is found.
42-
- Should return returns.result.Success[returns.maybe.Nothing] if the order is not
43-
found or if access is denied.
43+
- Should return returns.result.Success[returns.maybe.Nothing] if the order is not found or if access is denied.
4444
- Returning returns.result.Failure[Exception] will result in a 500.
4545
"""
4646

@@ -50,7 +50,7 @@
5050

5151
GetOrderStatuses = Callable[
5252
[str, str | None, int, Request],
53-
Coroutine[Any, Any, ResultE[tuple[list[T], Maybe[str]]]],
53+
Coroutine[Any, Any, ResultE[Maybe[tuple[list[T], Maybe[str]]]]],
5454
]
5555
"""
5656
Type alias for an async function that gets statuses for the order with `order_id`.
@@ -64,8 +64,43 @@
6464
Returns:
6565
A tuple containing a list of order statuses and a pagination token.
6666
67-
- Should return returns.result.Success[tuple[list[OrderStatus], returns.maybe.Some[str]] if order is found and including a pagination token.
68-
- Should return returns.result.Success[tuple[list[OrderStatus], returns.maybe.Nothing]] if order is found and not including a pagination token.
69-
- Should return returns.result.Failure[Exception] if the order is not found or if access is denied.
67+
- Should return returns.result.Success[returns.maybe.Some[tuple[list[OrderStatus], returns.maybe.Some[str]]] if order is found and including a pagination token.
68+
- Should return returns.result.Success[returns.maybe.Some[tuple[list[OrderStatus], returns.maybe.Nothing]]] if order is found and not including a pagination token.
69+
- Should return returns.result.Success[returns.maybe.Nothing] if the order is not found or if access is denied.
70+
- Returning returns.result.Failure[Exception] will result in a 500.
71+
"""
72+
73+
GetOpportunitySearchRecords = Callable[
74+
[str | None, int, Request],
75+
Coroutine[Any, Any, ResultE[tuple[list[OpportunitySearchRecord], Maybe[str]]]],
76+
]
77+
"""
78+
Type alias for an async function that gets OpportunitySearchRecords for all products.
79+
80+
Args:
81+
request (Request): FastAPI's Request object.
82+
next (str | None): A pagination token.
83+
limit (int): The maximum number of search records to return in a page.
84+
85+
Returns:
86+
- Should return returns.result.Success[tuple[list[OpportunitySearchRecord], returns.maybe.Some[str]]] if including a pagination token
87+
- Should return returns.result.Success[tuple[list[OpportunitySearchRecord], returns.maybe.Nothing]] if not including a pagination token
88+
- Returning returns.result.Failure[Exception] will result in a 500.
89+
"""
90+
91+
GetOpportunitySearchRecord = Callable[
92+
[str, Request], Coroutine[Any, Any, ResultE[Maybe[OpportunitySearchRecord]]]
93+
]
94+
"""
95+
Type alias for an async function that gets the OpportunitySearchRecord with
96+
`search_record_id`.
97+
98+
Args:
99+
search_record_id (str): The ID of the OpportunitySearchRecord.
100+
request (Request): FastAPI's Request object.
101+
102+
Returns:
103+
- Should return returns.result.Success[returns.maybe.Some[OpportunitySearchRecord]] if the search record is found.
104+
- Should return returns.result.Success[returns.maybe.Nothing] if the search record is not found or if access is denied.
70105
- Returning returns.result.Failure[Exception] will result in a 500.
71106
"""

src/stapi_fastapi/constants.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
1-
STAPI_VERSION = "0.0.0.pre"
21
TYPE_JSON = "application/json"
32
TYPE_GEOJSON = "application/geo+json"

src/stapi_fastapi/models/conformance.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
CORE = "https://stapi.example.com/v0.1.0/core"
44
OPPORTUNITIES = "https://stapi.example.com/v0.1.0/opportunities"
5+
ASYNC_OPPORTUNITIES = "https://stapi.example.com/v0.1.0/async-opportunities"
56

67

78
class Conformance(BaseModel):

src/stapi_fastapi/models/opportunity.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
from enum import StrEnum
12
from typing import Any, Literal, TypeVar
23

34
from geojson_pydantic import Feature, FeatureCollection
45
from geojson_pydantic.geometries import Geometry
5-
from pydantic import BaseModel, ConfigDict, Field
6+
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
67

78
from stapi_fastapi.models.shared import Link
89
from stapi_fastapi.types.datetime_interval import DatetimeInterval
@@ -45,3 +46,38 @@ class Opportunity(Feature[G, P]):
4546
class OpportunityCollection(FeatureCollection[Opportunity[G, P]]):
4647
type: Literal["FeatureCollection"] = "FeatureCollection"
4748
links: list[Link] = Field(default_factory=list)
49+
id: str | None = None
50+
51+
52+
class OpportunitySearchStatusCode(StrEnum):
53+
received = "received"
54+
in_progress = "in_progress"
55+
failed = "failed"
56+
canceled = "canceled"
57+
completed = "completed"
58+
59+
60+
class OpportunitySearchStatus(BaseModel):
61+
timestamp: AwareDatetime
62+
status_code: OpportunitySearchStatusCode
63+
reason_code: str | None = None
64+
reason_text: str | None = None
65+
links: list[Link] = Field(default_factory=list)
66+
67+
68+
class OpportunitySearchRecord(BaseModel):
69+
id: str
70+
product_id: str
71+
opportunity_request: OpportunityPayload
72+
status: OpportunitySearchStatus
73+
links: list[Link] = Field(default_factory=list)
74+
75+
76+
class OpportunitySearchRecords(BaseModel):
77+
search_records: list[OpportunitySearchRecord]
78+
links: list[Link] = Field(default_factory=list)
79+
80+
81+
class Prefer(StrEnum):
82+
respond_async = "respond-async"
83+
wait = "wait"

src/stapi_fastapi/models/product.py

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
if TYPE_CHECKING:
1313
from stapi_fastapi.backends.product_backend import (
1414
CreateOrder,
15+
GetOpportunityCollection,
1516
SearchOpportunities,
17+
SearchOpportunitiesAsync,
1618
)
1719

1820

@@ -54,46 +56,88 @@ class Product(BaseModel):
5456
_opportunity_properties: type[OpportunityProperties]
5557
_order_parameters: type[OrderParameters]
5658
_create_order: CreateOrder
57-
_search_opportunities: SearchOpportunities
59+
_search_opportunities: SearchOpportunities | None
60+
_search_opportunities_async: SearchOpportunitiesAsync | None
61+
_get_opportunity_collection: GetOpportunityCollection | None
5862

5963
def __init__(
6064
self,
6165
*args,
62-
create_order: CreateOrder,
63-
search_opportunities: SearchOpportunities,
6466
constraints: type[Constraints],
6567
opportunity_properties: type[OpportunityProperties],
6668
order_parameters: type[OrderParameters],
69+
create_order: CreateOrder,
70+
search_opportunities: SearchOpportunities | None = None,
71+
search_opportunities_async: SearchOpportunitiesAsync | None = None,
72+
get_opportunity_collection: GetOpportunityCollection | None = None,
6773
**kwargs,
6874
) -> None:
6975
super().__init__(*args, **kwargs)
70-
self._create_order = create_order
71-
self._search_opportunities = search_opportunities
76+
77+
if bool(search_opportunities_async) != bool(get_opportunity_collection):
78+
raise ValueError(
79+
"Both the `search_opportunities_async` and `get_opportunity_collection` "
80+
"arguments must be provided if either is provided"
81+
)
82+
7283
self._constraints = constraints
7384
self._opportunity_properties = opportunity_properties
7485
self._order_parameters = order_parameters
86+
self._create_order = create_order
87+
self._search_opportunities = search_opportunities
88+
self._search_opportunities_async = search_opportunities_async
89+
self._get_opportunity_collection = get_opportunity_collection
7590

7691
@property
77-
def create_order(self: Self) -> CreateOrder:
92+
def create_order(self) -> CreateOrder:
7893
return self._create_order
7994

8095
@property
81-
def search_opportunities(self: Self) -> SearchOpportunities:
96+
def search_opportunities(self) -> SearchOpportunities:
97+
if not self._search_opportunities:
98+
raise AttributeError("This product does not support opportunity search")
8299
return self._search_opportunities
83100

84101
@property
85-
def constraints(self: Self) -> type[Constraints]:
102+
def search_opportunities_async(self) -> SearchOpportunitiesAsync:
103+
if not self._search_opportunities_async:
104+
raise AttributeError(
105+
"This product does not support async opportunity search"
106+
)
107+
return self._search_opportunities_async
108+
109+
@property
110+
def get_opportunity_collection(self) -> GetOpportunityCollection:
111+
if not self._get_opportunity_collection:
112+
raise AttributeError(
113+
"This product does not support async opportunity search"
114+
)
115+
return self._get_opportunity_collection
116+
117+
@property
118+
def constraints(self) -> type[Constraints]:
86119
return self._constraints
87120

88121
@property
89-
def opportunity_properties(self: Self) -> type[OpportunityProperties]:
122+
def opportunity_properties(self) -> type[OpportunityProperties]:
90123
return self._opportunity_properties
91124

92125
@property
93-
def order_parameters(self: Self) -> type[OrderParameters]:
126+
def order_parameters(self) -> type[OrderParameters]:
94127
return self._order_parameters
95128

96-
def with_links(self: Self, links: list[Link] | None = None) -> Self:
129+
@property
130+
def supports_opportunity_search(self) -> bool:
131+
return self._search_opportunities is not None
132+
133+
@property
134+
def supports_async_opportunity_search(self) -> bool:
135+
return (
136+
self._search_opportunities_async is not None
137+
and self._get_opportunity_collection is not None
138+
)
139+
140+
def with_links(self, links: list[Link] | None = None) -> Self:
97141
if not links:
98142
return self
99143

src/stapi_fastapi/models/shared.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Self
1+
from typing import Any
22

33
from pydantic import (
44
AnyUrl,
@@ -28,5 +28,5 @@ def __init__(self, href: AnyUrl | str, **kwargs):
2828
# overriding the default serialization to filter None field values from
2929
# dumped json
3030
@model_serializer(mode="wrap", when_used="json")
31-
def serialize(self: Self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
31+
def serialize(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
3232
return {k: v for k, v in handler(self).items() if v is not None}

0 commit comments

Comments
 (0)