Skip to content

Commit cc07fa0

Browse files
philvarnerPhil Varner
andauthored
Fix pagination links for Orders pagination wrt the use of overriding url_for (#124)
## What I'm changing Currently, the pagination links for Orders and Order Statuses don't have `next` link relation hrefs with the value returned from url_for, so they're always relative to the server's URL (default) rather than what the implementation has overridden url_for to do. ## How I did it - the pagination link function calls the url_for function now, so the url is correct ## Checklist - [X] Tests pass: `uv run pytest` - [X] Checks pass: `uv run pre-commit run --all-files` - [X] CHANGELOG is updated (if necessary) --------- Co-authored-by: Phil Varner <[email protected]>
1 parent 2e5def5 commit cc07fa0

File tree

7 files changed

+93
-110
lines changed

7 files changed

+93
-110
lines changed

stapi-fastapi/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a
1212
that will be returned from pagination. When this returns `Some(int)`, the value is used for the `numberMatched` field in
1313
FeatureCollection returned from the /orders endpoint. If this feature is not desired, providing a function that returns
1414
`Nothing` will exclude the `numberMatched` field in the response.
15+
- ProductRouter and RootRouter now have a method `url_for` that makes the link generation code slightly cleaner and
16+
allows for overridding in child classes, to support proxy rewrite of the links.
1517

1618
### Removed
1719

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from typing import Any
2+
3+
from fastapi import (
4+
APIRouter,
5+
Request,
6+
)
7+
from fastapi.datastructures import URL
8+
9+
10+
class StapiFastapiBaseRouter(APIRouter):
11+
@staticmethod
12+
def url_for(request: Request, name: str, /, **path_params: Any) -> URL:
13+
return request.url_for(name, **path_params)

stapi-fastapi/src/stapi_fastapi/routers/product_router.py

Lines changed: 17 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from typing import TYPE_CHECKING, Any
66

77
from fastapi import (
8-
APIRouter,
98
Depends,
109
Header,
1110
HTTPException,
@@ -38,6 +37,7 @@
3837
from stapi_fastapi.errors import NotFoundError, QueryablesError
3938
from stapi_fastapi.models.product import Product
4039
from stapi_fastapi.responses import GeoJSONResponse
40+
from stapi_fastapi.routers.base import StapiFastapiBaseRouter
4141
from stapi_fastapi.routers.route_names import (
4242
CONFORMANCE,
4343
CREATE_ORDER,
@@ -47,6 +47,7 @@
4747
GET_QUERYABLES,
4848
SEARCH_OPPORTUNITIES,
4949
)
50+
from stapi_fastapi.routers.utils import json_link
5051

5152
if TYPE_CHECKING:
5253
from stapi_fastapi.routers import RootRouter
@@ -84,7 +85,7 @@ def build_conformances(product: Product, root_router: RootRouter) -> list[str]:
8485
return list(conformances)
8586

8687

87-
class ProductRouter(APIRouter):
88+
class ProductRouter(StapiFastapiBaseRouter):
8889
# FIXME ruff is complaining that the init is too complex
8990
def __init__( # noqa
9091
self,
@@ -199,31 +200,16 @@ async def _create_order(
199200
tags=["Products"],
200201
)
201202

202-
@staticmethod
203-
def url_for(request: Request, name: str, /, **path_params: Any) -> str:
204-
return str(request.url_for(name, **path_params))
205-
206203
def get_product(self, request: Request) -> ProductPydantic:
207204
links = [
208-
Link(
209-
href=self.url_for(request, f"{self.root_router.name}:{self.product.id}:{GET_PRODUCT}"),
210-
rel="self",
211-
type=TYPE_JSON,
212-
),
213-
Link(
214-
href=self.url_for(request, f"{self.root_router.name}:{self.product.id}:{CONFORMANCE}"),
215-
rel="conformance",
216-
type=TYPE_JSON,
205+
json_link("self", self.url_for(request, f"{self.root_router.name}:{self.product.id}:{GET_PRODUCT}")),
206+
json_link("conformance", self.url_for(request, f"{self.root_router.name}:{self.product.id}:{CONFORMANCE}")),
207+
json_link(
208+
"queryables", self.url_for(request, f"{self.root_router.name}:{self.product.id}:{GET_QUERYABLES}")
217209
),
218-
Link(
219-
href=self.url_for(request, f"{self.root_router.name}:{self.product.id}:{GET_QUERYABLES}"),
220-
rel="queryables",
221-
type=TYPE_JSON,
222-
),
223-
Link(
224-
href=self.url_for(request, f"{self.root_router.name}:{self.product.id}:{GET_ORDER_PARAMETERS}"),
225-
rel="order-parameters",
226-
type=TYPE_JSON,
210+
json_link(
211+
"order-parameters",
212+
self.url_for(request, f"{self.root_router.name}:{self.product.id}:{GET_ORDER_PARAMETERS}"),
227213
),
228214
Link(
229215
href=self.url_for(request, f"{self.root_router.name}:{self.product.id}:{CREATE_ORDER}"),
@@ -237,10 +223,9 @@ def get_product(self, request: Request) -> ProductPydantic:
237223
self.product.supports_async_opportunity_search and self.root_router.supports_async_opportunity_search
238224
):
239225
links.append(
240-
Link(
241-
href=self.url_for(request, f"{self.root_router.name}:{self.product.id}:{SEARCH_OPPORTUNITIES}"),
242-
rel="opportunities",
243-
type=TYPE_JSON,
226+
json_link(
227+
"opportunities",
228+
self.url_for(request, f"{self.root_router.name}:{self.product.id}:{SEARCH_OPPORTUNITIES}"),
244229
),
245230
)
246231

@@ -411,7 +396,7 @@ def pagination_link(self, request: Request, opp_req: OpportunityPayload, paginat
411396
body = opp_req.body()
412397
body["next"] = pagination_token
413398
return Link(
414-
href=str(request.url),
399+
href=request.url,
415400
rel="next",
416401
type=TYPE_JSON,
417402
method="POST",
@@ -431,14 +416,13 @@ async def get_opportunity_collection(
431416
):
432417
case Success(Some(opportunity_collection)):
433418
opportunity_collection.links.append(
434-
Link(
435-
href=self.url_for(
419+
json_link(
420+
"self",
421+
self.url_for(
436422
request,
437423
f"{self.root_router.name}:{self.product.id}:{GET_OPPORTUNITY_COLLECTION}",
438424
opportunity_collection_id=opportunity_collection_id,
439425
),
440-
rel="self",
441-
type=TYPE_JSON,
442426
),
443427
)
444428
return opportunity_collection # type: ignore

stapi-fastapi/src/stapi_fastapi/routers/root_router.py

Lines changed: 49 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
import traceback
33
from typing import Any
44

5-
from fastapi import APIRouter, HTTPException, Request, status
5+
from fastapi import HTTPException, Request, status
6+
from fastapi.datastructures import URL
67
from returns.maybe import Maybe, Some
78
from returns.result import Failure, Success
89
from stapi_pydantic import (
@@ -28,10 +29,11 @@
2829
GetOrderStatuses,
2930
)
3031
from stapi_fastapi.conformance import API as API_CONFORMANCE
31-
from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON
32+
from stapi_fastapi.constants import TYPE_GEOJSON
3233
from stapi_fastapi.errors import NotFoundError
3334
from stapi_fastapi.models.product import Product
3435
from stapi_fastapi.responses import GeoJSONResponse
36+
from stapi_fastapi.routers.base import StapiFastapiBaseRouter
3537
from stapi_fastapi.routers.product_router import ProductRouter
3638
from stapi_fastapi.routers.route_names import (
3739
CONFORMANCE,
@@ -44,11 +46,12 @@
4446
LIST_PRODUCTS,
4547
ROOT,
4648
)
49+
from stapi_fastapi.routers.utils import json_link
4750

4851
logger = logging.getLogger(__name__)
4952

5053

51-
class RootRouter(APIRouter):
54+
class RootRouter(StapiFastapiBaseRouter):
5255
def __init__(
5356
self,
5457
get_orders: GetOrders,
@@ -170,50 +173,35 @@ def __init__(
170173

171174
self.conformances = list(_conformances)
172175

173-
@staticmethod
174-
def url_for(request: Request, name: str, /, **path_params: Any) -> str:
175-
return str(request.url_for(name, **path_params))
176-
177176
def get_root(self, request: Request) -> RootResponse:
178177
links = [
179-
Link(
180-
href=self.url_for(request, f"{self.name}:{ROOT}"),
181-
rel="self",
182-
type=TYPE_JSON,
178+
json_link(
179+
"self",
180+
self.url_for(request, f"{self.name}:{ROOT}"),
183181
),
184-
Link(
185-
href=self.url_for(request, self.openapi_endpoint_name),
186-
rel="service-description",
187-
type=TYPE_JSON,
182+
json_link(
183+
"service-description",
184+
self.url_for(request, self.openapi_endpoint_name),
188185
),
189186
Link(
190-
href=self.url_for(request, self.docs_endpoint_name),
191187
rel="service-docs",
188+
href=self.url_for(request, self.docs_endpoint_name),
192189
type="text/html",
193190
),
191+
json_link("conformance", href=self.url_for(request, f"{self.name}:{CONFORMANCE}")),
192+
json_link("products", self.url_for(request, f"{self.name}:{LIST_PRODUCTS}")),
194193
Link(
195-
href=self.url_for(request, f"{self.name}:{CONFORMANCE}"),
196-
rel="conformance",
197-
type=TYPE_JSON,
198-
),
199-
Link(
200-
href=self.url_for(request, f"{self.name}:{LIST_PRODUCTS}"),
201-
rel="products",
202-
type=TYPE_JSON,
203-
),
204-
Link(
205-
href=self.url_for(request, f"{self.name}:{LIST_ORDERS}"),
206194
rel="orders",
195+
href=self.url_for(request, f"{self.name}:{LIST_ORDERS}"),
207196
type=TYPE_GEOJSON,
208197
),
209198
]
210199

211200
if self.supports_async_opportunity_search:
212201
links.append(
213-
Link(
214-
href=self.url_for(request, f"{self.name}:{LIST_OPPORTUNITY_SEARCH_RECORDS}"),
215-
rel="opportunity-search-records",
216-
type=TYPE_JSON,
202+
json_link(
203+
"opportunity-search-records",
204+
self.url_for(request, f"{self.name}:{LIST_OPPORTUNITY_SEARCH_RECORDS}"),
217205
),
218206
)
219207

@@ -238,14 +226,13 @@ def get_products(self, request: Request, next: str | None = None, limit: int = 1
238226
end = start + limit
239227
ids = self.product_ids[start:end]
240228
links = [
241-
Link(
242-
href=self.url_for(request, f"{self.name}:{LIST_PRODUCTS}"),
243-
rel="self",
244-
type=TYPE_JSON,
229+
json_link(
230+
"self",
231+
self.url_for(request, f"{self.name}:{LIST_PRODUCTS}"),
245232
),
246233
]
247234
if end > 0 and end < len(self.product_ids):
248-
links.append(self.pagination_link(request, self.product_ids[end], limit))
235+
links.append(self.pagination_link(request, f"{self.name}:{LIST_PRODUCTS}", self.product_ids[end], limit))
249236
return ProductsCollection(
250237
products=[self.product_routers[product_id].get_product(request) for product_id in ids],
251238
links=links,
@@ -261,8 +248,8 @@ async def get_orders( # noqa: C901
261248
for order in orders:
262249
order.links.extend(self.order_links(order, request))
263250
match maybe_pagination_token:
264-
case Some(x):
265-
links.append(self.pagination_link(request, x, limit))
251+
case Some(next_):
252+
links.append(self.pagination_link(request, f"{self.name}:{LIST_ORDERS}", next_, limit))
266253
case Maybe.empty:
267254
pass
268255
match maybe_orders_count:
@@ -325,8 +312,12 @@ async def get_order_statuses(
325312
case Success(Some((statuses, maybe_pagination_token))):
326313
links.append(self.order_statuses_link(request, order_id))
327314
match maybe_pagination_token:
328-
case Some(x):
329-
links.append(self.pagination_link(request, x, limit))
315+
case Some(next_):
316+
links.append(
317+
self.pagination_link(
318+
request, f"{self.name}:{LIST_ORDER_STATUSES}", next_, limit, order_id=order_id
319+
)
320+
)
330321
case Maybe.empty:
331322
pass
332323
case Success(Maybe.empty):
@@ -353,10 +344,10 @@ def add_product(self, product: Product, *args: Any, **kwargs: Any) -> None:
353344
self.product_routers[product.id] = product_router
354345
self.product_ids = [*self.product_routers.keys()]
355346

356-
def generate_order_href(self, request: Request, order_id: str) -> str:
347+
def generate_order_href(self, request: Request, order_id: str) -> URL:
357348
return self.url_for(request, f"{self.name}:{GET_ORDER}", order_id=order_id)
358349

359-
def generate_order_statuses_href(self, request: Request, order_id: str) -> str:
350+
def generate_order_statuses_href(self, request: Request, order_id: str) -> URL:
360351
return self.url_for(request, f"{self.name}:{LIST_ORDER_STATUSES}", order_id=order_id)
361352

362353
def order_links(self, order: Order[OrderStatus], request: Request) -> list[Link]:
@@ -366,33 +357,19 @@ def order_links(self, order: Order[OrderStatus], request: Request) -> list[Link]
366357
rel="self",
367358
type=TYPE_GEOJSON,
368359
),
369-
Link(
370-
href=self.generate_order_statuses_href(request, order.id),
371-
rel="monitor",
372-
type=TYPE_JSON,
360+
json_link(
361+
"monitor",
362+
self.generate_order_statuses_href(request, order.id),
373363
),
374364
]
375365

376366
def order_statuses_link(self, request: Request, order_id: str) -> Link:
377-
return Link(
378-
href=self.url_for(
379-
request,
380-
f"{self.name}:{LIST_ORDER_STATUSES}",
381-
order_id=order_id,
382-
),
383-
rel="self",
384-
type=TYPE_JSON,
385-
)
386-
387-
def pagination_link(self, request: Request, pagination_token: str, limit: int) -> Link:
388-
href = str(request.url.include_query_params(next=pagination_token, limit=limit)).replace(
389-
str(request.url_for(f"{self.name}:{ROOT}")), self.url_for(request, f"{self.name}:{ROOT}"), 1
390-
)
367+
return json_link("self", self.url_for(request, f"{self.name}:{LIST_ORDER_STATUSES}", order_id=order_id))
391368

392-
return Link(
393-
href=href,
394-
rel="next",
395-
type=TYPE_JSON,
369+
def pagination_link(self, request: Request, name: str, pagination_token: str, limit: int, **kwargs: Any) -> Link:
370+
return json_link(
371+
"next",
372+
self.url_for(request, name, **kwargs).include_query_params(next=pagination_token, limit=limit),
396373
)
397374

398375
async def get_opportunity_search_records(
@@ -404,8 +381,12 @@ async def get_opportunity_search_records(
404381
for record in records:
405382
record.links.append(self.opportunity_search_record_self_link(record, request))
406383
match maybe_pagination_token:
407-
case Some(x):
408-
links.append(self.pagination_link(request, x, limit))
384+
case Some(next_):
385+
links.append(
386+
self.pagination_link(
387+
request, f"{self.name}:{LIST_OPPORTUNITY_SEARCH_RECORDS}", next_, limit
388+
)
389+
)
409390
case Maybe.empty:
410391
pass
411392
case Failure(ValueError()):
@@ -470,7 +451,7 @@ async def get_opportunity_search_record_statuses(
470451
case _:
471452
raise AssertionError("Expected code to be unreachable")
472453

473-
def generate_opportunity_search_record_href(self, request: Request, search_record_id: str) -> str:
454+
def generate_opportunity_search_record_href(self, request: Request, search_record_id: str) -> URL:
474455
return self.url_for(
475456
request,
476457
f"{self.name}:{GET_OPPORTUNITY_SEARCH_RECORD}",
@@ -480,11 +461,7 @@ def generate_opportunity_search_record_href(self, request: Request, search_recor
480461
def opportunity_search_record_self_link(
481462
self, opportunity_search_record: OpportunitySearchRecord, request: Request
482463
) -> Link:
483-
return Link(
484-
href=self.generate_opportunity_search_record_href(request, opportunity_search_record.id),
485-
rel="self",
486-
type=TYPE_JSON,
487-
)
464+
return json_link("self", self.generate_opportunity_search_record_href(request, opportunity_search_record.id))
488465

489466
@property
490467
def _get_order_statuses(self) -> GetOrderStatuses: # type: ignore
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from fastapi.datastructures import URL
2+
from stapi_pydantic import Link
3+
4+
from stapi_fastapi.constants import TYPE_JSON
5+
6+
7+
def json_link(rel: str, href: URL) -> Link:
8+
return Link(href=href, rel=rel, type=TYPE_JSON)

stapi-pydantic/CHANGELOG.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
1212

1313
- pydantic >= 2.12 is now required.
1414

15-
### Added
15+
### Changed
1616

17-
- ProductRouter and RootRouter now have a method `url_for` that makes the link generation code slightly cleaner and
18-
allows for overridding in child classes, to support proxy rewrite of the links.
17+
- pydantic >= 2.12 is now required.
1918

2019
## [0.0.4] - 2025-07-17
2120

0 commit comments

Comments
 (0)