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

Commit 40a208d

Browse files
author
Phil Varner
authored
add support for order statuses endpoints (#119)
* add support for order statuses endpoints * add monitor link to Order * update changelog * allow extra fields in OrderStatus * generify interfaces to allow use of OrderStatus subclasses, update docstrings * update signature to use generic type
1 parent c523496 commit 40a208d

File tree

10 files changed

+326
-88
lines changed

10 files changed

+326
-88
lines changed

CHANGELOG.md

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,35 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
66
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
77

88

9+
## [unreleased]
10+
11+
### Added
12+
13+
- Endpoint `/orders/{order_id}/statuses` supporting `GET` for retrieving statuses. The entity returned by this conforms
14+
to the change proposed in [stapi-spec#239](https://github.com/stapi-spec/stapi-spec/pull/239).
15+
- Endpoint `/orders/{order_id}/statuses` supporting `POST` for updating current status
16+
- RootBackend has new methods `get_order_statuses` and `set_order_status`
17+
18+
### Changed
19+
20+
- OrderRequest renamed to OrderPayload
21+
22+
### Deprecated
23+
24+
none
25+
26+
### Removed
27+
28+
none
29+
30+
### Fixed
31+
32+
none
33+
34+
### Security
35+
36+
none
37+
938
## [0.4.0] - 2024-12-11
1039

1140
### Added
@@ -44,6 +73,8 @@ none
4473

4574
- OrderStatusCode and ProviderRole are now StrEnum instead of (str, Enum)
4675
- All types using `Result[A, Exception]` have been replace with the equivalent type `ResultE[A]`
76+
- Order and OrderCollection extend _GeoJsonBase instead of Feature and FeatureCollection, to allow for tighter
77+
constraints on fields
4778

4879
### Deprecated
4980

@@ -75,8 +106,6 @@ none
75106
- Order field `id` must be a string, instead of previously allowing int. This is because while an
76107
order ID may an integral numeric value, it is not a "number" in the sense that math will be performed
77108
order ID values, so string represents this better.
78-
- Order and OrderCollection extend _GeoJsonBase instead of Feature and FeatureCollection, to allow for tighter
79-
constraints on fields
80109

81110
### Deprecated
82111

src/stapi_fastapi/backends/product_backend.py

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

88
from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest
9-
from stapi_fastapi.models.order import Order, OrderRequest
9+
from stapi_fastapi.models.order import Order, OrderPayload
1010
from stapi_fastapi.routers.product_router import ProductRouter
1111

1212

@@ -27,7 +27,7 @@ async def search_opportunities(
2727
async def create_order(
2828
self,
2929
product_router: ProductRouter,
30-
search: OrderRequest,
30+
search: OrderPayload,
3131
request: Request,
3232
) -> ResultE[Order]:
3333
"""

src/stapi_fastapi/backends/root_backend.py

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@
44
from returns.maybe import Maybe
55
from returns.result import ResultE
66

7-
from stapi_fastapi.models.order import Order, OrderCollection
7+
from stapi_fastapi.models.order import (
8+
Order,
9+
OrderCollection,
10+
OrderStatus,
11+
OrderStatusPayload,
12+
)
813

914

10-
class RootBackend(Protocol): # pragma: nocover
15+
class RootBackend[T: OrderStatusPayload, U: OrderStatus](Protocol): # pragma: nocover
1116
async def get_orders(self, request: Request) -> ResultE[OrderCollection]:
1217
"""
1318
Return a list of existing orders.
@@ -21,9 +26,37 @@ async def get_order(self, order_id: str, request: Request) -> ResultE[Maybe[Orde
2126
Should return returns.results.Success[Order] if order is found.
2227
2328
Should return returns.results.Failure[returns.maybe.Nothing] if the order is
24-
not found or if access is denied. If there is an Exception associated with attempting to find the order,
25-
then resturns.results.Failure[returns.maybe.Some[Exception]] should be returned.
29+
not found or if access is denied.
2630
27-
Typically, a Failure[Nothing] will result in a 404 and Failure[Some[Exception]] will resulting in a 500.
31+
A Failure[Exception] will result in a 500.
32+
"""
33+
...
34+
35+
async def get_order_statuses(
36+
self, order_id: str, request: Request
37+
) -> ResultE[list[U]]:
38+
"""
39+
Get statuses for order with `order_id`.
40+
41+
Should return returns.results.Success[list[OrderStatus]] if order is found.
42+
43+
Should return returns.results.Failure[Exception] if the order is
44+
not found or if access is denied.
45+
46+
A Failure[Exception] will result in a 500.
47+
"""
48+
...
49+
50+
async def set_order_status(
51+
self, order_id: str, payload: T, request: Request
52+
) -> ResultE[U]:
53+
"""
54+
Set statuses for order with `order_id`.
55+
56+
Should return returns.results.Success[OrderStatus] if successful.
57+
58+
Should return returns.results.Failure[Exception] if the status was not able to be set.
59+
60+
A Failure[Exception] will result in a 500.
2861
"""
2962
...

src/stapi_fastapi/models/order.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,6 @@ class OrderParameters(BaseModel):
2929
ORP = TypeVar("ORP", bound=OrderParameters)
3030

3131

32-
class OrderRequest(BaseModel, Generic[ORP]):
33-
datetime: DatetimeInterval
34-
geometry: Geometry
35-
# TODO: validate the CQL2 filter?
36-
filter: CQL2Filter | None = None
37-
38-
order_parameters: ORP
39-
40-
model_config = ConfigDict(strict=True)
41-
42-
4332
class OrderStatusCode(StrEnum):
4433
received = "received"
4534
accepted = "accepted"
@@ -55,6 +44,13 @@ class OrderStatus(BaseModel):
5544
reason_text: Optional[str] = None
5645
links: list[Link] = Field(default_factory=list)
5746

47+
model_config = ConfigDict(extra="allow")
48+
49+
50+
class OrderStatuses[T: OrderStatus](BaseModel):
51+
statuses: list[T]
52+
links: list[Link] = Field(default_factory=list)
53+
5854

5955
class OrderSearchParameters(BaseModel):
6056
datetime: DatetimeInterval
@@ -63,10 +59,10 @@ class OrderSearchParameters(BaseModel):
6359
filter: CQL2Filter | None = None
6460

6561

66-
class OrderProperties(BaseModel):
62+
class OrderProperties[T: OrderStatus](BaseModel):
6763
product_id: str
6864
created: AwareDatetime
69-
status: OrderStatus
65+
status: T
7066

7167
search_parameters: OrderSearchParameters
7268
opportunity_properties: dict[str, Any]
@@ -115,3 +111,23 @@ def __len__(self) -> int:
115111
def __getitem__(self, index: int) -> Order:
116112
"""get feature at a given index"""
117113
return self.features[index]
114+
115+
116+
class OrderPayload(BaseModel, Generic[ORP]):
117+
datetime: DatetimeInterval
118+
geometry: Geometry
119+
# TODO: validate the CQL2 filter?
120+
filter: CQL2Filter | None = None
121+
122+
order_parameters: ORP
123+
124+
model_config = ConfigDict(strict=True)
125+
126+
127+
class OrderStatusPayload(BaseModel):
128+
status_code: OrderStatusCode | None = None
129+
reason_code: str | None = None
130+
reason_text: str | None = None
131+
132+
# todo: rework generic types to allow subclasses to be used correctly, and remove extra=allow
133+
model_config = ConfigDict(strict=True, extra="allow")

src/stapi_fastapi/routers/product_router.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@
77
from geojson_pydantic.geometries import Geometry
88
from returns.result import Failure, Success
99

10-
from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON
10+
from stapi_fastapi.constants import TYPE_JSON
1111
from stapi_fastapi.exceptions import ConstraintsException
1212
from stapi_fastapi.models.opportunity import (
1313
OpportunityCollection,
1414
OpportunityRequest,
1515
)
16-
from stapi_fastapi.models.order import Order, OrderRequest
16+
from stapi_fastapi.models.order import Order, OrderPayload
1717
from stapi_fastapi.models.product import Product
1818
from stapi_fastapi.models.shared import Link
1919
from stapi_fastapi.responses import GeoJSONResponse
@@ -85,13 +85,13 @@ def __init__(
8585
# the annotation on every `ProductRouter` instance's `create_order`, not just
8686
# this one's.
8787
async def _create_order(
88-
payload: OrderRequest,
88+
payload: OrderPayload,
8989
request: Request,
9090
response: Response,
9191
) -> Order:
9292
return await self.create_order(payload, request, response)
9393

94-
_create_order.__annotations__["payload"] = OrderRequest[
94+
_create_order.__annotations__["payload"] = OrderPayload[
9595
self.product.order_parameters # type: ignore
9696
]
9797

@@ -203,7 +203,7 @@ def get_product_order_parameters(self: Self) -> JsonSchemaModel:
203203
return self.product.order_parameters
204204

205205
async def create_order(
206-
self, payload: OrderRequest, request: Request, response: Response
206+
self, payload: OrderPayload, request: Request, response: Response
207207
) -> Order:
208208
"""
209209
Create a new order.
@@ -214,8 +214,8 @@ async def create_order(
214214
request,
215215
):
216216
case Success(order):
217+
self.root_router.add_order_links(order, request)
217218
location = str(self.root_router.generate_order_href(request, order.id))
218-
order.links.append(Link(href=location, rel="self", type=TYPE_GEOJSON))
219219
response.headers["Location"] = location
220220
return order
221221
case Failure(e) if isinstance(e, ConstraintsException):

src/stapi_fastapi/routers/root_router.py

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,20 @@
33

44
from fastapi import APIRouter, HTTPException, Request, status
55
from fastapi.datastructures import URL
6+
from fastapi.responses import Response
67
from returns.maybe import Maybe, Some
78
from returns.result import Failure, Success
89

910
from stapi_fastapi.backends.root_backend import RootBackend
1011
from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON
1112
from stapi_fastapi.exceptions import NotFoundException
1213
from stapi_fastapi.models.conformance import CORE, Conformance
13-
from stapi_fastapi.models.order import Order, OrderCollection
14+
from stapi_fastapi.models.order import (
15+
Order,
16+
OrderCollection,
17+
OrderStatuses,
18+
OrderStatusPayload,
19+
)
1420
from stapi_fastapi.models.product import Product, ProductsCollection
1521
from stapi_fastapi.models.root import RootResponse
1622
from stapi_fastapi.models.shared import Link
@@ -84,6 +90,22 @@ def __init__(
8490
tags=["Orders"],
8591
)
8692

93+
self.add_api_route(
94+
"/orders/{order_id}/statuses",
95+
self.get_order_statuses,
96+
methods=["GET"],
97+
name=f"{self.name}:list-order-statuses",
98+
tags=["Orders"],
99+
)
100+
101+
self.add_api_route(
102+
"/orders/{order_id}/statuses",
103+
self.set_order_status,
104+
methods=["POST"],
105+
name=f"{self.name}:set-order-status",
106+
tags=["Orders"],
107+
)
108+
87109
def get_root(self, request: Request) -> RootResponse:
88110
return RootResponse(
89111
id="STAPI API",
@@ -168,9 +190,7 @@ async def get_order(self: Self, order_id: str, request: Request) -> Order:
168190
"""
169191
match await self.backend.get_order(order_id, request):
170192
case Success(Some(order)):
171-
order.links.append(
172-
Link(href=str(request.url), rel="self", type=TYPE_GEOJSON)
173-
)
193+
self.add_order_links(order, request)
174194
return order
175195
case Success(Maybe.empty):
176196
raise NotFoundException("Order not found")
@@ -185,11 +205,78 @@ async def get_order(self: Self, order_id: str, request: Request) -> Order:
185205
case _:
186206
raise AssertionError("Expected code to be unreachable")
187207

208+
async def get_order_statuses(
209+
self: Self, order_id: str, request: Request
210+
) -> OrderStatuses:
211+
match await self.backend.get_order_statuses(order_id, request):
212+
case Success(statuses):
213+
return OrderStatuses(
214+
statuses=statuses,
215+
links=[
216+
Link(
217+
href=str(
218+
request.url_for(
219+
f"{self.name}:list-order-statuses",
220+
order_id=order_id,
221+
)
222+
),
223+
rel="self",
224+
type=TYPE_JSON,
225+
)
226+
],
227+
)
228+
case Failure(e):
229+
logging.exception(
230+
"An error occurred while retrieving order statuses", e
231+
)
232+
raise HTTPException(
233+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
234+
detail="Error finding Order Statuses",
235+
)
236+
case _:
237+
raise AssertionError("Expected code to be unreachable")
238+
239+
async def set_order_status(
240+
self, order_id: str, payload: OrderStatusPayload, request: Request
241+
) -> Response:
242+
match await self.backend.set_order_status(order_id, payload, request):
243+
case Success(_):
244+
return Response(status_code=status.HTTP_202_ACCEPTED)
245+
case Failure(e):
246+
logging.exception("An error occurred while setting order status", e)
247+
raise HTTPException(
248+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
249+
detail="Error setting Order Status",
250+
)
251+
case x:
252+
raise AssertionError(f"Expected code to be unreachable {x}")
253+
188254
def add_product(self: Self, product: Product) -> None:
189255
# Give the include a prefix from the product router
190256
product_router = ProductRouter(product, self)
191257
self.include_router(product_router, prefix=f"/products/{product.id}")
192258
self.product_routers[product.id] = product_router
193259

194-
def generate_order_href(self: Self, request: Request, order_id: int | str) -> URL:
260+
def generate_order_href(self: Self, request: Request, order_id: str) -> URL:
195261
return request.url_for(f"{self.name}:get-order", order_id=order_id)
262+
263+
def generate_order_statuses_href(
264+
self: Self, request: Request, order_id: str
265+
) -> URL:
266+
return request.url_for(f"{self.name}:list-order-statuses", order_id=order_id)
267+
268+
def add_order_links(self, order: Order, request: Request):
269+
order.links.append(
270+
Link(
271+
href=str(self.generate_order_href(request, order.id)),
272+
rel="self",
273+
type=TYPE_GEOJSON,
274+
)
275+
)
276+
order.links.append(
277+
Link(
278+
href=str(self.generate_order_statuses_href(request, order.id)),
279+
rel="monitor",
280+
type=TYPE_JSON,
281+
),
282+
)

0 commit comments

Comments
 (0)