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

Commit 67ea960

Browse files
Feat/tr/pagination (#123)
* feat: adding token based pagination + limit to 4 endpoints * feat: updated backend models which now can accept a limit and token argument return tuples of data and if applicable, a pagination token * feat: paginated endpoints return 'next' Link object that should be used to paginate through results. * tests: creating pagination tester to isolate pagination checks into a uniform checker and make test logic more straightforward and readable * feat: adding mock backend implementations for testing/demonstration purposes in applicaton.py * feat: moved link object creation in paginated endpoints to separate functions to improve endpoint business logic readability
1 parent 0a5d877 commit 67ea960

File tree

12 files changed

+645
-161
lines changed

12 files changed

+645
-161
lines changed

.github/workflows/python-publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
- name: Set up Python
2525
uses: actions/setup-python@v5
2626
with:
27-
python-version: "3.x"
27+
python-version: "3.12.x"
2828
- name: Install dependencies
2929
run: |
3030
python -m pip install --upgrade pip

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ 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]. Limit and token are passed in as query params for `GET` endpoints, and via the body aas separte key/value pairs for `POST` requests.
18+
19+
If pagination is available and more records remain the response object will contain a `next` link object that can be used to get the next page of results. No `next` `Link` returned indicates there are no further records available.
20+
21+
`limit` defaults to 10 and maxes at 100.
22+
1123

1224
## ADRs
1325

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

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

poetry.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/stapi_fastapi/backends/product_backend.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import Protocol
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
@@ -16,9 +17,11 @@ async def search_opportunities(
1617
product_router: ProductRouter,
1718
search: OpportunityRequest,
1819
request: Request,
19-
) -> ResultE[list[Opportunity]]:
20+
next: str | None,
21+
limit: int,
22+
) -> ResultE[tuple[list[Opportunity], Maybe[str]]]:
2023
"""
21-
Search for ordering opportunities for the given search parameters.
24+
Search for ordering opportunities for the given search parameters and return pagination token if applicable.
2225
2326
Backends must validate search constraints and return
2427
`stapi_fastapi.exceptions.ConstraintsException` if not valid.

src/stapi_fastapi/backends/root_backend.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,16 @@
66

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

1312

1413
class RootBackend[T: OrderStatus](Protocol): # pragma: nocover
15-
async def get_orders(self, request: Request) -> ResultE[OrderCollection]:
14+
async def get_orders(
15+
self, request: Request, next: str | None, limit: int
16+
) -> ResultE[tuple[list[Order], Maybe[str]]]:
1617
"""
17-
Return a list of existing orders.
18+
Return a list of existing orders and pagination token if applicable.
1819
"""
1920
...
2021

@@ -24,18 +25,18 @@ async def get_order(self, order_id: str, request: Request) -> ResultE[Maybe[Orde
2425
2526
Should return returns.results.Success[Order] if order is found.
2627
27-
Should return returns.results.Failure[returns.maybe.Nothing] if the order is
28-
not found or if access is denied.
28+
Should return returns.results.Failure[returns.maybe.Nothing] if the
29+
order is not found or if access is denied.
2930
3031
A Failure[Exception] will result in a 500.
3132
"""
3233
...
3334

3435
async def get_order_statuses(
35-
self, order_id: str, request: Request
36-
) -> ResultE[list[T]]:
36+
self, order_id: str, request: Request, next: str | None, limit: int
37+
) -> ResultE[tuple[list[T], Maybe[str]]]:
3738
"""
38-
Get statuses for order with `order_id`.
39+
Get statuses for order with `order_id` and return pagination token if applicable
3940
4041
Should return returns.results.Success[list[OrderStatus]] if order is found.
4142

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

src/stapi_fastapi/routers/root_router.py

Lines changed: 85 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def __init__(
4242
self.conformances = conformances
4343
self.openapi_endpoint_name = openapi_endpoint_name
4444
self.docs_endpoint_name = docs_endpoint_name
45+
self.product_ids: list[str] = []
4546

4647
# A dict is used to track the product routers so we can ensure
4748
# idempotentcy in case a product is added multiple times, and also to
@@ -140,34 +141,52 @@ def get_root(self, request: Request) -> RootResponse:
140141
def get_conformance(self, request: Request) -> Conformance:
141142
return Conformance(conforms_to=self.conformances)
142143

143-
def get_products(self, request: Request) -> ProductsCollection:
144+
def get_products(
145+
self, request: Request, next: str | None = None, limit: int = 10
146+
) -> ProductsCollection:
147+
start = 0
148+
limit = min(limit, 100)
149+
try:
150+
if next:
151+
start = self.product_ids.index(next)
152+
except ValueError:
153+
logging.exception("An error occurred while retrieving products")
154+
raise NotFoundException(
155+
detail="Error finding pagination token for products"
156+
) from None
157+
end = start + limit
158+
ids = self.product_ids[start:end]
159+
links = [
160+
Link(
161+
href=str(request.url_for(f"{self.name}:list-products")),
162+
rel="self",
163+
type=TYPE_JSON,
164+
),
165+
]
166+
if end > 0 and end < len(self.product_ids):
167+
links.append(self.pagination_link(request, self.product_ids[end]))
144168
return ProductsCollection(
145-
products=[pr.get_product(request) for pr in self.product_routers.values()],
146-
links=[
147-
Link(
148-
href=str(request.url_for(f"{self.name}:list-products")),
149-
rel="self",
150-
type=TYPE_JSON,
151-
)
169+
products=[
170+
self.product_routers[product_id].get_product(request)
171+
for product_id in ids
152172
],
173+
links=links,
153174
)
154175

155-
async def get_orders(self, request: Request) -> OrderCollection:
156-
match await self.backend.get_orders(request):
157-
case Success(orders):
176+
async def get_orders(
177+
self, request: Request, next: str | None = None, limit: int = 10
178+
) -> OrderCollection:
179+
links: list[Link] = []
180+
match await self.backend.get_orders(request, next, limit):
181+
case Success((orders, Some(pagination_token))):
158182
for order in orders:
159-
order.links.append(
160-
Link(
161-
href=str(
162-
request.url_for(
163-
f"{self.name}:get-order", order_id=order.id
164-
)
165-
),
166-
rel="self",
167-
type=TYPE_JSON,
168-
)
169-
)
170-
return orders
183+
order.links.append(self.order_link(request, order))
184+
links.append(self.pagination_link(request, pagination_token))
185+
case Success((orders, Nothing)): # noqa: F841
186+
for order in orders:
187+
order.links.append(self.order_link(request, order))
188+
case Failure(ValueError()):
189+
raise NotFoundException(detail="Error finding pagination token")
171190
case Failure(e):
172191
logger.error(
173192
"An error occurred while retrieving orders: %s",
@@ -179,6 +198,7 @@ async def get_orders(self, request: Request) -> OrderCollection:
179198
)
180199
case _:
181200
raise AssertionError("Expected code to be unreachable")
201+
return OrderCollection(features=orders, links=links)
182202

183203
async def get_order(self: Self, order_id: str, request: Request) -> Order:
184204
"""
@@ -204,25 +224,21 @@ async def get_order(self: Self, order_id: str, request: Request) -> Order:
204224
raise AssertionError("Expected code to be unreachable")
205225

206226
async def get_order_statuses(
207-
self: Self, order_id: str, request: Request
227+
self: Self,
228+
order_id: str,
229+
request: Request,
230+
next: str | None = None,
231+
limit: int = 10,
208232
) -> OrderStatuses:
209-
match await self.backend.get_order_statuses(order_id, request):
210-
case Success(statuses):
211-
return OrderStatuses(
212-
statuses=statuses,
213-
links=[
214-
Link(
215-
href=str(
216-
request.url_for(
217-
f"{self.name}:list-order-statuses",
218-
order_id=order_id,
219-
)
220-
),
221-
rel="self",
222-
type=TYPE_JSON,
223-
)
224-
],
225-
)
233+
links: list[Link] = []
234+
match await self.backend.get_order_statuses(order_id, request, next, limit):
235+
case Success((statuses, Some(pagination_token))):
236+
links.append(self.order_statuses_link(request, order_id))
237+
links.append(self.pagination_link(request, pagination_token))
238+
case Success((statuses, Nothing)): # noqa: F841
239+
links.append(self.order_statuses_link(request, order_id))
240+
case Failure(KeyError()):
241+
raise NotFoundException("Error finding pagination token")
226242
case Failure(e):
227243
logger.error(
228244
"An error occurred while retrieving order statuses: %s",
@@ -234,12 +250,14 @@ async def get_order_statuses(
234250
)
235251
case _:
236252
raise AssertionError("Expected code to be unreachable")
253+
return OrderStatuses(statuses=statuses, links=links)
237254

238255
def add_product(self: Self, product: Product, *args, **kwargs) -> None:
239256
# Give the include a prefix from the product router
240257
product_router = ProductRouter(product, self, *args, **kwargs)
241258
self.include_router(product_router, prefix=f"/products/{product.id}")
242259
self.product_routers[product.id] = product_router
260+
self.product_ids = [*self.product_routers.keys()]
243261

244262
def generate_order_href(self: Self, request: Request, order_id: str) -> URL:
245263
return request.url_for(f"{self.name}:get-order", order_id=order_id)
@@ -264,3 +282,29 @@ def add_order_links(self, order: Order, request: Request):
264282
type=TYPE_JSON,
265283
),
266284
)
285+
286+
def order_link(self, request: Request, order: Order):
287+
return Link(
288+
href=str(request.url_for(f"{self.name}:get-order", order_id=order.id)),
289+
rel="self",
290+
type=TYPE_JSON,
291+
)
292+
293+
def order_statuses_link(self, request: Request, order_id: str):
294+
return Link(
295+
href=str(
296+
request.url_for(
297+
f"{self.name}:list-order-statuses",
298+
order_id=order_id,
299+
)
300+
),
301+
rel="self",
302+
type=TYPE_JSON,
303+
)
304+
305+
def pagination_link(self, request: Request, pagination_token: str):
306+
return Link(
307+
href=str(request.url.include_query_params(next=pagination_token)),
308+
rel="next",
309+
type=TYPE_JSON,
310+
)

0 commit comments

Comments
 (0)