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

Commit bc4e7e0

Browse files
committed
Merge branch 'main' into pjh/convert-backend-protocols-to-callables
2 parents c6ca74b + 67ea960 commit bc4e7e0

File tree

11 files changed

+676
-168
lines changed

11 files changed

+676
-168
lines changed
Lines changed: 26 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,36 @@
1-
name: Build Python Package
1+
name: PR Checks
22

33
on:
4-
push:
5-
branches:
6-
- main
74
pull_request:
8-
branches:
9-
- main
10-
release:
11-
types:
12-
- published
5+
branches: ["main"]
136

147
jobs:
15-
build-package:
8+
test:
169
runs-on: ubuntu-latest
17-
environment:
18-
name: pypi
19-
url: https://pypi.org/p/stapi-fastapi
20-
permissions:
21-
id-token: write
10+
strategy:
11+
matrix:
12+
python-version: ["3.12", "3.13"]
2213
steps:
2314
- uses: actions/checkout@v4
24-
- name: Set up Python
25-
uses: actions/setup-python@v5
15+
- uses: actions/setup-python@v5
2616
with:
27-
python-version: "3.x"
28-
- name: Install dependencies
17+
python-version: ${{ matrix.python-version }}
18+
- name: Cache dependencies
19+
uses: actions/cache@v3
20+
with:
21+
path: |
22+
~/.cache/pip
23+
.venv
24+
key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }}
25+
restore-keys: |
26+
${{ runner.os }}-pip-${{ matrix.python-version }}-
27+
${{ runner.os }}-pip-
28+
- name: Install
29+
run: |
30+
python -m pip install poetry==1.7.1
31+
poetry install --with=dev
32+
- name: Lint
2933
run: |
30-
python -m pip install --upgrade pip
31-
pip install build
32-
pip install .
33-
- name: Build package
34-
run: python -m build
35-
- name: Publish package distributions to PyPI
36-
uses: pypa/gh-action-pypi-publish@release/v1
37-
if: startsWith(github.ref, 'refs/tags')
34+
poetry run pre-commit run --all-files
35+
- name: Test
36+
run: poetry run pytest

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@ guaranteed to be not correct.
88
STAPI FastAPI provides an `fastapi.APIRouter` which must be included in
99
`fastapi.FastAPI` instance.
1010

11+
### Pagination
12+
13+
4 endpoints currently offer pagination:
14+
`GET`: `'/orders`, `/products`, `/orders/{order_id}/statuses`
15+
`POST`: `/opportunities`.
16+
17+
Pagination is token based and follows recommendations in the [STAC API pagination].
18+
Limit and token are passed in as query params for `GET` endpoints, and via the body as
19+
separate key/value pairs for `POST` requests.
20+
21+
If pagination is available and more records remain the response object will contain a
22+
`next` link object that can be used to get the next page of results. No `next` `Link`
23+
returned indicates there are no further records available.
24+
25+
`limit` defaults to 10 and maxes at 100.
1126

1227
## ADRs
1328

@@ -59,3 +74,4 @@ With the `uvicorn` defaults the app should be accessible at
5974

6075
[STAPI spec]: https://github.com/stapi-spec/stapi-spec
6176
[poetry]: https://python-poetry.org/
77+
[STAC API pagination]: https://github.com/radiantearth/stac-api-spec/blob/release/v1.0.0/item-search/examples.md#paging-examples

src/stapi_fastapi/backends/product_backend.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@
33
from typing import Any, Callable, Coroutine
44

55
from fastapi import Request
6+
from returns.maybe import Maybe
67
from returns.result import ResultE
78

89
from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest
910
from stapi_fastapi.models.order import Order, OrderPayload
1011
from stapi_fastapi.routers.product_router import ProductRouter
1112

1213
SearchOpportunities = Callable[
13-
[ProductRouter, OpportunityRequest, Request],
14-
Coroutine[Any, Any, ResultE[list[Opportunity]]],
14+
[ProductRouter, OpportunityRequest, Request, str | None, int],
15+
Coroutine[Any, Any, ResultE[tuple[list[Opportunity], Maybe[str]]]],
1516
]
1617
"""
1718
Type alias for an async function that searches for ordering opportunities for the given
@@ -21,13 +22,19 @@
2122
product_router (ProductRouter): The product router.
2223
search (OpportunityRequest): The search parameters.
2324
request (Request): FastAPI's Request object.
25+
next (str | None): A pagination token.
26+
limit (int): The maximum number of opportunities to return in a page.
2427
2528
Returns:
26-
- Should return returns.result.Success[list[Opportunity]]
29+
A tuple containing a list of opportunities and a pagination token.
30+
31+
- Should return returns.result.Success[tuple[list[Opportunity], returns.maybe.Some[str]]] if including a pagination token
32+
- Should return returns.result.Success[tuple[list[Opportunity], returns.maybe.Nothing]] if not including a pagination token
2733
- Returning returns.result.Failure[Exception] will result in a 500.
2834
29-
Backends must validate search constraints and return
30-
returns.result.Failure[stapi_fastapi.exceptions.ConstraintsException] if not valid.
35+
Note:
36+
Backends must validate search constraints and return
37+
returns.result.Failure[stapi_fastapi.exceptions.ConstraintsException] if not valid.
3138
"""
3239

3340
CreateOrder = Callable[
@@ -45,6 +52,7 @@
4552
- Should return returns.result.Success[Order]
4653
- Returning returns.result.Failure[Exception] will result in a 500.
4754
48-
Backends must validate order payload and return
49-
returns.result.Failure[stapi_fastapi.exceptions.ConstraintsException] if not valid.
55+
Note:
56+
Backends must validate order payload and return
57+
returns.result.Failure[stapi_fastapi.exceptions.ConstraintsException] if not valid.
5058
"""

src/stapi_fastapi/backends/root_backend.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,26 @@
66

77
from stapi_fastapi.models.order import (
88
Order,
9-
OrderCollection,
109
OrderStatus,
1110
)
1211

13-
GetOrders = Callable[[Request], Coroutine[Any, Any, ResultE[OrderCollection]]]
12+
GetOrders = Callable[
13+
[Request, str | None, int],
14+
Coroutine[Any, Any, ResultE[tuple[list[Order], Maybe[str]]]],
15+
]
1416
"""
1517
Type alias for an async function that returns a list of existing Orders.
1618
1719
Args:
1820
request (Request): FastAPI's Request object.
21+
next (str | None): A pagination token.
22+
limit (int): The maximum number of orders to return in a page.
1923
2024
Returns:
21-
- Should return returns.result.Success[OrderCollection]
25+
A tuple containing a list of orders and a pagination token.
26+
27+
- Should return returns.result.Success[tuple[list[Order], returns.maybe.Some[str]]] if including a pagination token
28+
- Should return returns.result.Success[tuple[list[Order], returns.maybe.Nothing]] if not including a pagination token
2229
- Returning returns.result.Failure[Exception] will result in a 500.
2330
"""
2431

@@ -37,21 +44,33 @@
3744
- Returning returns.result.Failure[Exception] will result in a 500.
3845
"""
3946

47+
# async def get_order_statuses(
48+
# self, order_id: str, request: Request, next: str | None, limit: int
49+
# ) -> ResultE[tuple[list[T], Maybe[str]]]:
50+
# """
51+
# Get statuses for order with `order_id` and return pagination token if applicable
4052

4153
T = TypeVar("T", bound=OrderStatus)
4254

4355

44-
GetOrderStatuses = Callable[[str, Request], Coroutine[Any, Any, ResultE[list[T]]]]
56+
GetOrderStatuses = Callable[
57+
[str, Request, str | None, int],
58+
Coroutine[Any, Any, ResultE[tuple[list[T], Maybe[str]]]],
59+
]
4560
"""
4661
Type alias for an async function that gets statuses for the order with `order_id`.
4762
4863
Args:
4964
order_id (str): The order ID.
5065
request (Request): FastAPI's Request object.
66+
next (str | None): A pagination token.
67+
limit (int): The maximum number of statuses to return in a page.
5168
5269
Returns:
53-
- Should return returns.result.Success[list[OrderStatus]] if order is found.
54-
- Should return returns.result.Failure[Exception] if the order is not found or if
55-
access is denied.
70+
A tuple containing a list of order statuses and a pagination token.
71+
72+
- Should return returns.result.Success[tuple[list[OrderStatus], returns.maybe.Some[str]] if order is found and including a pagination token.
73+
- Should return returns.result.Success[tuple[list[OrderStatus], returns.maybe.Nothing]] if order is found and not including a pagination token.
74+
- Should return returns.result.Failure[Exception] if the order is not found or if access is denied.
5675
- Returning returns.result.Failure[Exception] will result in a 500.
5776
"""

src/stapi_fastapi/routers/product_router.py

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
import logging
44
import traceback
5-
from typing import TYPE_CHECKING, Self
5+
from typing import TYPE_CHECKING, Annotated, Self
66

7-
from fastapi import APIRouter, HTTPException, Request, Response, status
7+
from fastapi import APIRouter, Body, HTTPException, Request, Response, status
88
from geojson_pydantic.geometries import Geometry
9+
from returns.maybe import Some
910
from returns.result import Failure, Success
1011

1112
from stapi_fastapi.constants import TYPE_JSON
@@ -161,27 +162,29 @@ def get_product(self, request: Request) -> Product:
161162
)
162163

163164
async def search_opportunities(
164-
self, search: OpportunityRequest, request: Request
165+
self,
166+
search: OpportunityRequest,
167+
request: Request,
168+
next: Annotated[str | None, Body()] = None,
169+
limit: Annotated[int, Body()] = 10,
165170
) -> OpportunityCollection:
166171
"""
167172
Explore the opportunities available for a particular set of constraints
168173
"""
169-
match await self.product.search_opportunities(self, search, request):
170-
case Success(features):
171-
return OpportunityCollection(
172-
features=features,
173-
links=[
174-
Link(
175-
href=str(
176-
request.url_for(
177-
f"{self.root_router.name}:{self.product.id}:create-order",
178-
),
179-
),
180-
rel="create-order",
181-
type=TYPE_JSON,
182-
),
183-
],
184-
)
174+
links: list[Link] = []
175+
match await self.product._search_opportunities(
176+
self, search, request, next, limit
177+
):
178+
case Success((features, Some(pagination_token))):
179+
links.append(self.order_link(request))
180+
body = {
181+
"search": search.model_dump(mode="json"),
182+
"next": pagination_token,
183+
"limit": limit,
184+
}
185+
links.append(self.pagination_link(request, body))
186+
case Success((features, Nothing)): # noqa: F841
187+
links.append(self.order_link(request))
185188
case Failure(e) if isinstance(e, ConstraintsException):
186189
raise e
187190
case Failure(e):
@@ -195,6 +198,7 @@ async def search_opportunities(
195198
)
196199
case x:
197200
raise AssertionError(f"Expected code to be unreachable {x}")
201+
return OpportunityCollection(features=features, links=links)
198202

199203
def get_product_constraints(self: Self) -> JsonSchemaModel:
200204
"""
@@ -237,3 +241,24 @@ async def create_order(
237241
)
238242
case x:
239243
raise AssertionError(f"Expected code to be unreachable {x}")
244+
245+
def order_link(self, request: Request):
246+
return Link(
247+
href=str(
248+
request.url_for(
249+
f"{self.root_router.name}:{self.product.id}:create-order",
250+
),
251+
),
252+
rel="create-order",
253+
type=TYPE_JSON,
254+
method="POST",
255+
)
256+
257+
def pagination_link(self, request: Request, body: dict[str, str | dict]):
258+
return Link(
259+
href=str(request.url.remove_query_params(keys=["next", "limit"])),
260+
rel="next",
261+
type=TYPE_JSON,
262+
method="POST",
263+
body=body,
264+
)

0 commit comments

Comments
 (0)