Skip to content
This repository was archived by the owner on Apr 2, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,35 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).


## [unreleased]

### Added

- Endpoint `/orders/{order_id}/statuses` supporting `GET` for retrieving statuses. The entity returned by this conforms
to the change proposed in [stapi-spec#239](https://github.com/stapi-spec/stapi-spec/pull/239).
- Endpoint `/orders/{order_id}/statuses` supporting `POST` for updating current status
- RootBackend has new methods `get_order_statuses` and `set_order_status`

### Changed

- OrderRequest renamed to OrderPayload

### Deprecated

none

### Removed

none

### Fixed

none

### Security

none

## [0.4.0] - 2024-12-11

### Added
Expand Down Expand Up @@ -44,6 +73,8 @@ none

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

### Deprecated

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

### Deprecated

Expand Down
4 changes: 2 additions & 2 deletions src/stapi_fastapi/backends/product_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from returns.result import ResultE

from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest
from stapi_fastapi.models.order import Order, OrderRequest
from stapi_fastapi.models.order import Order, OrderPayload
from stapi_fastapi.routers.product_router import ProductRouter


Expand All @@ -27,7 +27,7 @@ async def search_opportunities(
async def create_order(
self,
product_router: ProductRouter,
search: OrderRequest,
search: OrderPayload,
request: Request,
) -> ResultE[Order]:
"""
Expand Down
43 changes: 38 additions & 5 deletions src/stapi_fastapi/backends/root_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@
from returns.maybe import Maybe
from returns.result import ResultE

from stapi_fastapi.models.order import Order, OrderCollection
from stapi_fastapi.models.order import (
Order,
OrderCollection,
OrderStatus,
OrderStatusPayload,
)


class RootBackend(Protocol): # pragma: nocover
class RootBackend[T: OrderStatusPayload, U: OrderStatus](Protocol): # pragma: nocover
async def get_orders(self, request: Request) -> ResultE[OrderCollection]:
"""
Return a list of existing orders.
Expand All @@ -21,9 +26,37 @@ async def get_order(self, order_id: str, request: Request) -> ResultE[Maybe[Orde
Should return returns.results.Success[Order] if order is found.

Should return returns.results.Failure[returns.maybe.Nothing] if the order is
not found or if access is denied. If there is an Exception associated with attempting to find the order,
then resturns.results.Failure[returns.maybe.Some[Exception]] should be returned.
not found or if access is denied.

Typically, a Failure[Nothing] will result in a 404 and Failure[Some[Exception]] will resulting in a 500.
A Failure[Exception] will result in a 500.
"""
...

async def get_order_statuses(
self, order_id: str, request: Request
) -> ResultE[list[U]]:
"""
Get statuses for order with `order_id`.

Should return returns.results.Success[list[OrderStatus]] if order is found.

Should return returns.results.Failure[Exception] if the order is
not found or if access is denied.

A Failure[Exception] will result in a 500.
"""
...

async def set_order_status(
self, order_id: str, payload: T, request: Request
) -> ResultE[U]:
"""
Set statuses for order with `order_id`.

Should return returns.results.Success[OrderStatus] if successful.

Should return returns.results.Failure[Exception] if the status was not able to be set.

A Failure[Exception] will result in a 500.
"""
...
42 changes: 29 additions & 13 deletions src/stapi_fastapi/models/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,6 @@ class OrderParameters(BaseModel):
ORP = TypeVar("ORP", bound=OrderParameters)


class OrderRequest(BaseModel, Generic[ORP]):
datetime: DatetimeInterval
geometry: Geometry
# TODO: validate the CQL2 filter?
filter: CQL2Filter | None = None

order_parameters: ORP

model_config = ConfigDict(strict=True)


class OrderStatusCode(StrEnum):
received = "received"
accepted = "accepted"
Expand All @@ -55,6 +44,13 @@ class OrderStatus(BaseModel):
reason_text: Optional[str] = None
links: list[Link] = Field(default_factory=list)

model_config = ConfigDict(extra="allow")


class OrderStatuses[T: OrderStatus](BaseModel):
statuses: list[T]
links: list[Link] = Field(default_factory=list)


class OrderSearchParameters(BaseModel):
datetime: DatetimeInterval
Expand All @@ -63,10 +59,10 @@ class OrderSearchParameters(BaseModel):
filter: CQL2Filter | None = None


class OrderProperties(BaseModel):
class OrderProperties[T: OrderStatus](BaseModel):
product_id: str
created: AwareDatetime
status: OrderStatus
status: T

search_parameters: OrderSearchParameters
opportunity_properties: dict[str, Any]
Expand Down Expand Up @@ -115,3 +111,23 @@ def __len__(self) -> int:
def __getitem__(self, index: int) -> Order:
"""get feature at a given index"""
return self.features[index]


class OrderPayload(BaseModel, Generic[ORP]):
datetime: DatetimeInterval
geometry: Geometry
# TODO: validate the CQL2 filter?
filter: CQL2Filter | None = None

order_parameters: ORP

model_config = ConfigDict(strict=True)


class OrderStatusPayload(BaseModel):
status_code: OrderStatusCode | None = None
reason_code: str | None = None
reason_text: str | None = None

# todo: rework generic types to allow subclasses to be used correctly, and remove extra=allow
model_config = ConfigDict(strict=True, extra="allow")
12 changes: 6 additions & 6 deletions src/stapi_fastapi/routers/product_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
from geojson_pydantic.geometries import Geometry
from returns.result import Failure, Success

from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON
from stapi_fastapi.constants import TYPE_JSON
from stapi_fastapi.exceptions import ConstraintsException
from stapi_fastapi.models.opportunity import (
OpportunityCollection,
OpportunityRequest,
)
from stapi_fastapi.models.order import Order, OrderRequest
from stapi_fastapi.models.order import Order, OrderPayload
from stapi_fastapi.models.product import Product
from stapi_fastapi.models.shared import Link
from stapi_fastapi.responses import GeoJSONResponse
Expand Down Expand Up @@ -85,13 +85,13 @@ def __init__(
# the annotation on every `ProductRouter` instance's `create_order`, not just
# this one's.
async def _create_order(
payload: OrderRequest,
payload: OrderPayload,
request: Request,
response: Response,
) -> Order:
return await self.create_order(payload, request, response)

_create_order.__annotations__["payload"] = OrderRequest[
_create_order.__annotations__["payload"] = OrderPayload[
self.product.order_parameters # type: ignore
]

Expand Down Expand Up @@ -203,7 +203,7 @@ def get_product_order_parameters(self: Self) -> JsonSchemaModel:
return self.product.order_parameters

async def create_order(
self, payload: OrderRequest, request: Request, response: Response
self, payload: OrderPayload, request: Request, response: Response
) -> Order:
"""
Create a new order.
Expand All @@ -214,8 +214,8 @@ async def create_order(
request,
):
case Success(order):
self.root_router.add_order_links(order, request)
location = str(self.root_router.generate_order_href(request, order.id))
order.links.append(Link(href=location, rel="self", type=TYPE_GEOJSON))
response.headers["Location"] = location
return order
case Failure(e) if isinstance(e, ConstraintsException):
Expand Down
97 changes: 92 additions & 5 deletions src/stapi_fastapi/routers/root_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@

from fastapi import APIRouter, HTTPException, Request, status
from fastapi.datastructures import URL
from fastapi.responses import Response
from returns.maybe import Maybe, Some
from returns.result import Failure, Success

from stapi_fastapi.backends.root_backend import RootBackend
from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON
from stapi_fastapi.exceptions import NotFoundException
from stapi_fastapi.models.conformance import CORE, Conformance
from stapi_fastapi.models.order import Order, OrderCollection
from stapi_fastapi.models.order import (
Order,
OrderCollection,
OrderStatuses,
OrderStatusPayload,
)
from stapi_fastapi.models.product import Product, ProductsCollection
from stapi_fastapi.models.root import RootResponse
from stapi_fastapi.models.shared import Link
Expand Down Expand Up @@ -84,6 +90,22 @@ def __init__(
tags=["Orders"],
)

self.add_api_route(
"/orders/{order_id}/statuses",
self.get_order_statuses,
methods=["GET"],
name=f"{self.name}:list-order-statuses",
tags=["Orders"],
)

self.add_api_route(
"/orders/{order_id}/statuses",
self.set_order_status,
methods=["POST"],
name=f"{self.name}:set-order-status",
tags=["Orders"],
)

def get_root(self, request: Request) -> RootResponse:
return RootResponse(
id="STAPI API",
Expand Down Expand Up @@ -168,9 +190,7 @@ async def get_order(self: Self, order_id: str, request: Request) -> Order:
"""
match await self.backend.get_order(order_id, request):
case Success(Some(order)):
order.links.append(
Link(href=str(request.url), rel="self", type=TYPE_GEOJSON)
)
self.add_order_links(order, request)
return order
case Success(Maybe.empty):
raise NotFoundException("Order not found")
Expand All @@ -185,11 +205,78 @@ async def get_order(self: Self, order_id: str, request: Request) -> Order:
case _:
raise AssertionError("Expected code to be unreachable")

async def get_order_statuses(
self: Self, order_id: str, request: Request
) -> OrderStatuses:
match await self.backend.get_order_statuses(order_id, request):
case Success(statuses):
return OrderStatuses(
statuses=statuses,
links=[
Link(
href=str(
request.url_for(
f"{self.name}:list-order-statuses",
order_id=order_id,
)
),
rel="self",
type=TYPE_JSON,
)
],
)
case Failure(e):
logging.exception(
"An error occurred while retrieving order statuses", e
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Error finding Order Statuses",
)
case _:
raise AssertionError("Expected code to be unreachable")

async def set_order_status(
self, order_id: str, payload: OrderStatusPayload, request: Request
) -> Response:
match await self.backend.set_order_status(order_id, payload, request):
case Success(_):
return Response(status_code=status.HTTP_202_ACCEPTED)
case Failure(e):
logging.exception("An error occurred while setting order status", e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Error setting Order Status",
)
case x:
raise AssertionError(f"Expected code to be unreachable {x}")

def add_product(self: Self, product: Product) -> None:
# Give the include a prefix from the product router
product_router = ProductRouter(product, self)
self.include_router(product_router, prefix=f"/products/{product.id}")
self.product_routers[product.id] = product_router

def generate_order_href(self: Self, request: Request, order_id: int | str) -> URL:
def generate_order_href(self: Self, request: Request, order_id: str) -> URL:
return request.url_for(f"{self.name}:get-order", order_id=order_id)

def generate_order_statuses_href(
self: Self, request: Request, order_id: str
) -> URL:
return request.url_for(f"{self.name}:list-order-statuses", order_id=order_id)

def add_order_links(self, order: Order, request: Request):
order.links.append(
Link(
href=str(self.generate_order_href(request, order.id)),
rel="self",
type=TYPE_GEOJSON,
)
)
order.links.append(
Link(
href=str(self.generate_order_statuses_href(request, order.id)),
rel="monitor",
type=TYPE_JSON,
),
)
Loading
Loading