Skip to content
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
15 changes: 15 additions & 0 deletions stapi-fastapi/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.

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).

## [0.8.0] - 2025-12-03

### Added

- RootRouter Callable `get_orders` now requires an additional value in the result tuple that is a count of the total number of orders
that will be returned from pagination. When this returns `Some(int)`, the value is used for the `numberMatched` field in
FeatureCollection returned from the /orders endpoint. If this feature is not desired, providing a function that returns
`Nothing` will exclude the `numberMatched` field in the response.

### Removed

- removed dependency on `pyrfc3339` library, since only one function from it was used in tests and that function has
been removed in newer versions of the library.

## [0.7.1] - 2025-04-25

### Fixed
Expand Down Expand Up @@ -126,6 +140,7 @@ Initial release
- Add links `opportunities` and `create-order` to Product
- Add link `create-order` to OpportunityCollection

[0.8.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.8.0
[0.7.1]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.7.1
[0.7.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.7.0
[0.6.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.6.0
Expand Down
4 changes: 1 addition & 3 deletions stapi-fastapi/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "stapi-fastapi"
version = "0.7.1"
version = "0.8.0"
description = "Sensor Tasking API (STAPI) with FastAPI"
authors = [
{ name = "Christian Wygoda", email = "christian.wygoda@wygoda.net" },
Expand All @@ -20,8 +20,6 @@ dependencies = [
"returns>=0.23",
"nox>=2024.4.15",
"pydantic-settings>=2.2.1",
"pyrfc3339>=1.1",
"types-pyRFC3339>=1.1.1",
"uvicorn>=0.29.0",
"stapi-pydantic>=0.0.3",
]
Expand Down
2 changes: 1 addition & 1 deletion stapi-fastapi/src/stapi_fastapi/backends/root_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

GetOrders = Callable[
[str | None, int, Request],
Coroutine[Any, Any, ResultE[tuple[list[Order[OrderStatus]], Maybe[str]]]],
Coroutine[Any, Any, ResultE[tuple[list[Order[OrderStatus]], Maybe[str], Maybe[int]]]],
]
"""
Type alias for an async function that returns a list of existing Orders.
Expand Down
23 changes: 19 additions & 4 deletions stapi-fastapi/src/stapi_fastapi/routers/root_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,19 +251,25 @@ def get_products(self, request: Request, next: str | None = None, limit: int = 1
links=links,
)

async def get_orders(
async def get_orders( # noqa: C901
self, request: Request, next: str | None = None, limit: int = 10
) -> OrderCollection[OrderStatus]:
links: list[Link] = []
orders_count: int | None = None
match await self._get_orders(next, limit, request):
case Success((orders, maybe_pagination_token)):
case Success((orders, maybe_pagination_token, maybe_orders_count)):
for order in orders:
order.links.extend(self.order_links(order, request))
match maybe_pagination_token:
case Some(x):
links.append(self.pagination_link(request, x, limit))
case Maybe.empty:
pass
match maybe_orders_count:
case Some(x):
orders_count = x
case Maybe.empty:
pass
case Failure(ValueError()):
raise NotFoundError(detail="Error finding pagination token")
case Failure(e):
Expand All @@ -277,7 +283,12 @@ async def get_orders(
)
case _:
raise AssertionError("Expected code to be unreachable")
return OrderCollection(features=orders, links=links)

return OrderCollection(
features=orders,
links=links,
number_matched=orders_count,
)

async def get_order(self, order_id: str, request: Request) -> Order[OrderStatus]:
"""
Expand Down Expand Up @@ -374,8 +385,12 @@ def order_statuses_link(self, request: Request, order_id: str) -> Link:
)

def pagination_link(self, request: Request, pagination_token: str, limit: int) -> Link:
href = str(request.url.include_query_params(next=pagination_token, limit=limit)).replace(
str(request.url_for(f"{self.name}:{ROOT}")), self.url_for(request, f"{self.name}:{ROOT}"), 1
)

return Link(
href=str(request.url.include_query_params(next=pagination_token, limit=limit)),
href=href,
rel="next",
type=TYPE_JSON,
)
Expand Down
11 changes: 8 additions & 3 deletions stapi-fastapi/tests/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,15 @@
)


async def mock_get_orders(next: str | None, limit: int, request: Request) -> ResultE[tuple[list[Order], Maybe[str]]]:
async def mock_get_orders(
next: str | None,
limit: int,
request: Request,
) -> ResultE[tuple[list[Order], Maybe[str], Maybe[int]]]:
"""
Return orders from backend. Handle pagination/limit if applicable
"""
count = 314
try:
start = 0
limit = min(limit, 100)
Expand All @@ -37,8 +42,8 @@ async def mock_get_orders(next: str | None, limit: int, request: Request) -> Res
orders = [request.state._orders_db.get_order(order_id) for order_id in ids]

if end > 0 and end < len(order_ids):
return Success((orders, Some(request.state._orders_db._orders[order_ids[end]].id)))
return Success((orders, Nothing))
return Success((orders, Some(request.state._orders_db._orders[order_ids[end]].id), Some(count)))
return Success((orders, Nothing, Some(count)))
except Exception as e:
return Failure(e)

Expand Down
22 changes: 21 additions & 1 deletion stapi-fastapi/tests/test_datetime_interval.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from zoneinfo import ZoneInfo

from pydantic import BaseModel, ValidationError
from pyrfc3339.utils import format_timezone
from pytest import mark, raises
from stapi_pydantic import DatetimeInterval

Expand All @@ -14,6 +13,27 @@ class Model(BaseModel):
datetime: DatetimeInterval


# format_timezone was removed from pyrfc3339 (MIT) in v2.1, so included here now
def format_timezone(utcoffset):
"""
Return a string representing the timezone offset.
Remaining seconds are rounded to the nearest minute.

>>> format_timezone(3600)
'+01:00'
>>> format_timezone(5400)
'+01:30'
>>> format_timezone(-28800)
'-08:00'
"""

hours, seconds = divmod(abs(utcoffset), 3600)
minutes = round(float(seconds) / 60)
sign = "+" if utcoffset >= 0 else "-"

return f"{sign}{int(hours):02d}:{int(minutes):02d}"


def rfc3339_strftime(dt: datetime, format: str) -> str:
tds = int(round(dt.tzinfo.utcoffset(dt).total_seconds())) # type: ignore
long = format_timezone(tds)
Expand Down
3 changes: 1 addition & 2 deletions stapi-fastapi/tests/test_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@

def test_empty_order(stapi_client: TestClient):
res = stapi_client.get("/orders")
default_orders = {"type": "FeatureCollection", "features": [], "links": []}
assert res.status_code == status.HTTP_200_OK
assert res.headers["Content-Type"] == "application/geo+json"
assert res.json() == default_orders
assert res.json() == {"type": "FeatureCollection", "features": [], "links": [], "numberMatched": 314}


@pytest.fixture
Expand Down
7 changes: 6 additions & 1 deletion stapi-pydantic/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [0.1.0] - 2025-07-17

### Changed

- pydantic >= 2.12 is now required.

### Added

Expand Down Expand Up @@ -46,6 +50,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
Initial release.

[unreleased]: https://github.com/stapi-spec/pystapi/compare/stac-pydantic/stapi-pydantic%2Fv0.0.4...main
[0.1.0]: https://github.com/stapi-spec/pystapi/compare/stac-pydantic/stapi-pydantic%2Fv0.0.4...stapi-pydantic%2Fv0.1.0
[0.0.4]: https://github.com/stapi-spec/pystapi/compare/stac-pydantic/stapi-pydantic%2Fv0.0.3...stapi-pydantic%2Fv0.0.4
[0.0.3]: https://github.com/stapi-spec/pystapi/compare/stac-pydantic/stapi-pydantic%2Fv0.0.2...stapi-pydantic%2Fv0.0.3
[0.0.2]: https://github.com/stapi-spec/pystapi/compare/stac-pydantic/stapi-pydantic%2Fv0.0.1...stapi-pydantic%2Fv0.0.2
Expand Down
4 changes: 2 additions & 2 deletions stapi-pydantic/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
[project]
name = "stapi-pydantic"
version = "0.0.4"
version = "0.1.0"
description = "Pydantic models for Satellite Tasking API (STAPI) Specification"
readme = "README.md"
authors = [
{ name = "Phil Varner", email = "philvarner@gmail.com" },
{ name = "Pete Gadomski", email = "pete.gadomski@gmail.com" },
]
requires-python = ">=3.11"
dependencies = ["cql2>=0.3.6", "geojson-pydantic>=1.2.0"]
dependencies = ["pydantic>=2.12", "cql2>=0.3.6", "geojson-pydantic>=1.2.0"]

[project.scripts]
stapi-pydantic = "stapi_pydantic:main"
Expand Down
3 changes: 3 additions & 0 deletions stapi-pydantic/src/stapi_pydantic/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ class OrderCollection(_GeoJsonBase, Generic[T]):
type: Literal["FeatureCollection"] = "FeatureCollection"
features: list[Order[T]]
links: list[Link] = Field(default_factory=list)
number_matched: int | None = Field(
serialization_alias="numberMatched", default=None, exclude_if=lambda x: x is None
)

def __iter__(self) -> Iterator[Order[T]]: # type: ignore [override]
"""iterate over features"""
Expand Down
Loading