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
Show all changes
23 commits
Select commit Hold shift + click to select a range
45307b7
feat: add async opportunity models
pjhartzell Feb 4, 2025
9cdf3f2
feat: update backends for asynchronous opportunity searches
pjhartzell Feb 4, 2025
dd7c7ad
feat: update product model to support async and optional opportunity …
pjhartzell Feb 4, 2025
7a00095
feat: Add async and optional opportunity search to routers
pjhartzell Feb 4, 2025
37bda4b
fix: revert back to a clunky, but functional, opportunity search endp…
pjhartzell Feb 4, 2025
f21a152
tests: add tests for async opportunity searching
pjhartzell Feb 5, 2025
3db2a60
tests: add more async tests and fix a few bugs
pjhartzell Feb 6, 2025
d2b849a
Merge branch 'main' into pjh/asynchronous-opportunity-search
pjhartzell Feb 6, 2025
b775a1e
tests: add pagination tests for async search, fix a few bugs
pjhartzell Feb 6, 2025
f1e3086
tests: Add a custom marker for supplying mock products
pjhartzell Feb 6, 2025
73741b9
docs: update CHANGELOG
pjhartzell Feb 6, 2025
3ec6978
fix: fix db mutation bug, sundry fixes
pjhartzell Feb 7, 2025
8f1366d
review: class attributes always defined, remove implicit Self type
pjhartzell Feb 8, 2025
51166f6
review: Rename get_preference to get_prefer
pjhartzell Feb 8, 2025
c719d8d
review: sidecar fix for logging -> logger
pjhartzell Feb 8, 2025
ae1a670
review: logging.error -> logging.exception
pjhartzell Feb 8, 2025
822dae9
review: correct list-orders type to geo+json
pjhartzell Feb 8, 2025
e903c16
review: sidecar - remove unused STAPI_VERSION
pjhartzell Feb 8, 2025
df8003c
review: Use Generator instead of Iterator
pjhartzell Feb 10, 2025
34c248b
review: correct logger.exception/logger.error use
pjhartzell Feb 10, 2025
c47307d
review: simplify root router links
pjhartzell Feb 10, 2025
a6c927f
Merge branch 'main' into pjh/asynchronous-opportunity-search
pjhartzell Feb 10, 2025
3ac327d
fix: minor typo fixes in CHANGELOG
pjhartzell Feb 10, 2025
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
11 changes: 9 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

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

### Changed

Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ filterwarnings = [
"ignore:The 'app' shortcut is now deprecated.:DeprecationWarning",
"ignore:Pydantic serializer warnings:UserWarning",
]
markers = [
"mock_products",
]

[build-system]
requires = [
Expand Down
19 changes: 17 additions & 2 deletions src/stapi_fastapi/backends/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
from .product_backend import CreateOrder, SearchOpportunities
from .root_backend import GetOrder, GetOrders, GetOrderStatuses
from .product_backend import (
CreateOrder,
GetOpportunityCollection,
SearchOpportunities,
SearchOpportunitiesAsync,
)
from .root_backend import (
GetOpportunitySearchRecord,
GetOpportunitySearchRecords,
GetOrder,
GetOrders,
GetOrderStatuses,
)

__all__ = [
"CreateOrder",
"GetOpportunityCollection",
"GetOpportunitySearchRecord",
"GetOpportunitySearchRecords",
"GetOrder",
"GetOrders",
"GetOrderStatuses",
"SearchOpportunities",
"SearchOpportunitiesAsync",
]
51 changes: 49 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,12 @@
from returns.maybe import Maybe
from returns.result import ResultE

from stapi_fastapi.models.opportunity import Opportunity, OpportunityPayload
from stapi_fastapi.models.opportunity import (
Opportunity,
OpportunityCollection,
OpportunityPayload,
OpportunitySearchRecord,
)
from stapi_fastapi.models.order import Order, OrderPayload
from stapi_fastapi.routers.product_router import ProductRouter

Expand All @@ -20,7 +25,7 @@

Args:
product_router (ProductRouter): The product router.
search (OpportunityRequest): The search parameters.
search (OpportunityPayload): The search parameters.
next (str | None): A pagination token.
limit (int): The maximum number of opportunities to return in a page.
request (Request): FastAPI's Request object.
Expand All @@ -37,6 +42,48 @@
returns.result.Failure[stapi_fastapi.exceptions.ConstraintsException] if not valid.
"""

SearchOpportunitiesAsync = Callable[
[ProductRouter, OpportunityPayload, Request],
Coroutine[Any, Any, ResultE[OpportunitySearchRecord]],
]
"""
Type alias for an async function that starts an asynchronous search for ordering
opportunities for the given search parameters.

Args:
product_router (ProductRouter): The product router.
search (OpportunityPayload): The search parameters.
request (Request): FastAPI's Request object.

Returns:
- Should return returns.result.Success[OpportunitySearchRecord]
- Returning returns.result.Failure[Exception] will result in a 500.

Backends must validate search constraints and return
returns.result.Failure[stapi_fastapi.exceptions.ConstraintsException] if not valid.
"""

GetOpportunityCollection = Callable[
[ProductRouter, str, Request],
Coroutine[Any, Any, ResultE[Maybe[OpportunityCollection]]],
]
"""
Type alias for an async function that retrieves the opportunity collection with
`opportunity_collection_id`.

The opportunity collection is generated by an asynchronous opportunity search.

Args:
product_router (ProductRouter): The product router.
opportunity_collection_id (str): The ID of the opportunity collection.
request (Request): FastAPI's Request object.

Returns:
- Should return returns.result.Success[returns.maybe.Some[OpportunityCollection]] if the opportunity collection is found.
- Should return returns.result.Success[returns.maybe.Nothing] if the opportunity collection is not found or if access is denied.
- Returning returns.result.Failure[Exception] will result in a 500.
"""

CreateOrder = Callable[
[ProductRouter, OrderPayload, Request], Coroutine[Any, Any, ResultE[Order]]
]
Expand Down
47 changes: 41 additions & 6 deletions src/stapi_fastapi/backends/root_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from returns.maybe import Maybe
from returns.result import ResultE

from stapi_fastapi.models.opportunity import OpportunitySearchRecord
from stapi_fastapi.models.order import (
Order,
OrderStatus,
Expand Down Expand Up @@ -39,8 +40,7 @@

Returns:
- Should return returns.result.Success[returns.maybe.Some[Order]] if order is found.
- Should return returns.result.Success[returns.maybe.Nothing] if the order is not
found or if access is denied.
- Should return returns.result.Success[returns.maybe.Nothing] if the order is not found or if access is denied.
- Returning returns.result.Failure[Exception] will result in a 500.
"""

Expand All @@ -50,7 +50,7 @@

GetOrderStatuses = Callable[
[str, str | None, int, Request],
Coroutine[Any, Any, ResultE[tuple[list[T], Maybe[str]]]],
Coroutine[Any, Any, ResultE[Maybe[tuple[list[T], Maybe[str]]]]],
]
"""
Type alias for an async function that gets statuses for the order with `order_id`.
Expand All @@ -64,8 +64,43 @@
Returns:
A tuple containing a list of order statuses and a pagination token.

- Should return returns.result.Success[tuple[list[OrderStatus], returns.maybe.Some[str]] if order is found and including a pagination token.
- Should return returns.result.Success[tuple[list[OrderStatus], returns.maybe.Nothing]] if order is found and not including a pagination token.
- Should return returns.result.Failure[Exception] if the order is not found or if access is denied.
- Should return returns.result.Success[returns.maybe.Some[tuple[list[OrderStatus], returns.maybe.Some[str]]] if order is found and including a pagination token.
- Should return returns.result.Success[returns.maybe.Some[tuple[list[OrderStatus], returns.maybe.Nothing]]] if order is found and not including a pagination token.
- Should return returns.result.Success[returns.maybe.Nothing] if the order is not found or if access is denied.
- Returning returns.result.Failure[Exception] will result in a 500.
"""

GetOpportunitySearchRecords = Callable[
[str | None, int, Request],
Coroutine[Any, Any, ResultE[tuple[list[OpportunitySearchRecord], Maybe[str]]]],
]
"""
Type alias for an async function that gets OpportunitySearchRecords for all products.

Args:
request (Request): FastAPI's Request object.
next (str | None): A pagination token.
limit (int): The maximum number of search records to return in a page.

Returns:
- Should return returns.result.Success[tuple[list[OpportunitySearchRecord], returns.maybe.Some[str]]] if including a pagination token
- Should return returns.result.Success[tuple[list[OpportunitySearchRecord], returns.maybe.Nothing]] if not including a pagination token
- Returning returns.result.Failure[Exception] will result in a 500.
"""

GetOpportunitySearchRecord = Callable[
[str, Request], Coroutine[Any, Any, ResultE[Maybe[OpportunitySearchRecord]]]
]
"""
Type alias for an async function that gets the OpportunitySearchRecord with
`search_record_id`.

Args:
search_record_id (str): The ID of the OpportunitySearchRecord.
request (Request): FastAPI's Request object.

Returns:
- Should return returns.result.Success[returns.maybe.Some[OpportunitySearchRecord]] if the search record is found.
- Should return returns.result.Success[returns.maybe.Nothing] if the search record is not found or if access is denied.
- Returning returns.result.Failure[Exception] will result in a 500.
"""
1 change: 0 additions & 1 deletion src/stapi_fastapi/constants.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
STAPI_VERSION = "0.0.0.pre"
TYPE_JSON = "application/json"
TYPE_GEOJSON = "application/geo+json"
1 change: 1 addition & 0 deletions src/stapi_fastapi/models/conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

CORE = "https://stapi.example.com/v0.1.0/core"
OPPORTUNITIES = "https://stapi.example.com/v0.1.0/opportunities"
ASYNC_OPPORTUNITIES = "https://stapi.example.com/v0.1.0/async-opportunities"


class Conformance(BaseModel):
Expand Down
38 changes: 37 additions & 1 deletion src/stapi_fastapi/models/opportunity.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from enum import StrEnum
from typing import Any, Literal, TypeVar

from geojson_pydantic import Feature, FeatureCollection
from geojson_pydantic.geometries import Geometry
from pydantic import BaseModel, ConfigDict, Field
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field

from stapi_fastapi.models.shared import Link
from stapi_fastapi.types.datetime_interval import DatetimeInterval
Expand Down Expand Up @@ -45,3 +46,38 @@ class Opportunity(Feature[G, P]):
class OpportunityCollection(FeatureCollection[Opportunity[G, P]]):
type: Literal["FeatureCollection"] = "FeatureCollection"
links: list[Link] = Field(default_factory=list)
id: str | None = None


class OpportunitySearchStatusCode(StrEnum):
received = "received"
in_progress = "in_progress"
failed = "failed"
canceled = "canceled"
completed = "completed"


class OpportunitySearchStatus(BaseModel):
timestamp: AwareDatetime
status_code: OpportunitySearchStatusCode
reason_code: str | None = None
reason_text: str | None = None
links: list[Link] = Field(default_factory=list)


class OpportunitySearchRecord(BaseModel):
id: str
product_id: str
opportunity_request: OpportunityPayload
status: OpportunitySearchStatus
links: list[Link] = Field(default_factory=list)


class OpportunitySearchRecords(BaseModel):
search_records: list[OpportunitySearchRecord]
links: list[Link] = Field(default_factory=list)


class Prefer(StrEnum):
respond_async = "respond-async"
wait = "wait"
66 changes: 55 additions & 11 deletions src/stapi_fastapi/models/product.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
if TYPE_CHECKING:
from stapi_fastapi.backends.product_backend import (
CreateOrder,
GetOpportunityCollection,
SearchOpportunities,
SearchOpportunitiesAsync,
)


Expand Down Expand Up @@ -54,46 +56,88 @@ class Product(BaseModel):
_opportunity_properties: type[OpportunityProperties]
_order_parameters: type[OrderParameters]
_create_order: CreateOrder
_search_opportunities: SearchOpportunities
_search_opportunities: SearchOpportunities | None
_search_opportunities_async: SearchOpportunitiesAsync | None
_get_opportunity_collection: GetOpportunityCollection | None

def __init__(
self,
*args,
create_order: CreateOrder,
search_opportunities: SearchOpportunities,
constraints: type[Constraints],
opportunity_properties: type[OpportunityProperties],
order_parameters: type[OrderParameters],
create_order: CreateOrder,
search_opportunities: SearchOpportunities | None = None,
search_opportunities_async: SearchOpportunitiesAsync | None = None,
get_opportunity_collection: GetOpportunityCollection | None = None,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self._create_order = create_order
self._search_opportunities = search_opportunities

if bool(search_opportunities_async) != bool(get_opportunity_collection):
raise ValueError(
"Both the `search_opportunities_async` and `get_opportunity_collection` "
"arguments must be provided if either is provided"
)

self._constraints = constraints
self._opportunity_properties = opportunity_properties
self._order_parameters = order_parameters
self._create_order = create_order
self._search_opportunities = search_opportunities
self._search_opportunities_async = search_opportunities_async
self._get_opportunity_collection = get_opportunity_collection

@property
def create_order(self: Self) -> CreateOrder:
def create_order(self) -> CreateOrder:
return self._create_order

@property
def search_opportunities(self: Self) -> SearchOpportunities:
def search_opportunities(self) -> SearchOpportunities:
if not self._search_opportunities:
raise AttributeError("This product does not support opportunity search")
return self._search_opportunities

@property
def constraints(self: Self) -> type[Constraints]:
def search_opportunities_async(self) -> SearchOpportunitiesAsync:
if not self._search_opportunities_async:
raise AttributeError(
"This product does not support async opportunity search"
)
return self._search_opportunities_async

@property
def get_opportunity_collection(self) -> GetOpportunityCollection:
if not self._get_opportunity_collection:
raise AttributeError(
"This product does not support async opportunity search"
)
return self._get_opportunity_collection

@property
def constraints(self) -> type[Constraints]:
return self._constraints

@property
def opportunity_properties(self: Self) -> type[OpportunityProperties]:
def opportunity_properties(self) -> type[OpportunityProperties]:
return self._opportunity_properties

@property
def order_parameters(self: Self) -> type[OrderParameters]:
def order_parameters(self) -> type[OrderParameters]:
return self._order_parameters

def with_links(self: Self, links: list[Link] | None = None) -> Self:
@property
def supports_opportunity_search(self) -> bool:
return self._search_opportunities is not None

@property
def supports_async_opportunity_search(self) -> bool:
return (
self._search_opportunities_async is not None
and self._get_opportunity_collection is not None
)

def with_links(self, links: list[Link] | None = None) -> Self:
if not links:
return self

Expand Down
4 changes: 2 additions & 2 deletions src/stapi_fastapi/models/shared.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Self
from typing import Any

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