Skip to content
Open
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
3 changes: 0 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,20 @@ repos:
language: system
types: [python]
pass_filenames: false
verbose: true

- id: ruff-format
name: Format with ruff
entry: uv run ruff format
language: system
types: [python]
pass_filenames: false
verbose: true

- id: mypy
name: Check typing with mypy
entry: uv run mypy
language: system
types: [python]
pass_filenames: false
verbose: true

- id: pymarkdown
name: Markdownlint
Expand Down
2 changes: 2 additions & 0 deletions stapi-fastapi/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a
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.
- ProductRouter and RootRouter now have a method `url_for` that makes the link generation code slightly cleaner and
allows for overridding in child classes, to support proxy rewrite of the links.

### Removed

Expand Down
13 changes: 13 additions & 0 deletions stapi-fastapi/src/stapi_fastapi/routers/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from typing import Any

from fastapi import (
APIRouter,
Request,
)
from starlette.datastructures import URL


class StapiFastapiBaseRouter(APIRouter):
@staticmethod
def url_for(request: Request, name: str, /, **path_params: Any) -> URL:
return request.url_for(name, **path_params)
10 changes: 3 additions & 7 deletions stapi-fastapi/src/stapi_fastapi/routers/product_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from typing import TYPE_CHECKING, Any

from fastapi import (
APIRouter,
Depends,
Header,
HTTPException,
Expand Down Expand Up @@ -38,6 +37,7 @@
from stapi_fastapi.errors import NotFoundError, QueryablesError
from stapi_fastapi.models.product import Product
from stapi_fastapi.responses import GeoJSONResponse
from stapi_fastapi.routers.base import StapiFastapiBaseRouter
from stapi_fastapi.routers.route_names import (
CONFORMANCE,
CREATE_ORDER,
Expand Down Expand Up @@ -84,7 +84,7 @@ def build_conformances(product: Product, root_router: RootRouter) -> list[str]:
return list(conformances)


class ProductRouter(APIRouter):
class ProductRouter(StapiFastapiBaseRouter):
# FIXME ruff is complaining that the init is too complex
def __init__( # noqa
self,
Expand Down Expand Up @@ -199,10 +199,6 @@ async def _create_order(
tags=["Products"],
)

@staticmethod
def url_for(request: Request, name: str, /, **path_params: Any) -> str:
return str(request.url_for(name, **path_params))

def get_product(self, request: Request) -> ProductPydantic:
links = [
Link(
Expand Down Expand Up @@ -411,7 +407,7 @@ def pagination_link(self, request: Request, opp_req: OpportunityPayload, paginat
body = opp_req.body()
body["next"] = pagination_token
return Link(
href=str(request.url),
href=request.url,
rel="next",
type=TYPE_JSON,
method="POST",
Expand Down
46 changes: 24 additions & 22 deletions stapi-fastapi/src/stapi_fastapi/routers/root_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import traceback
from typing import Any

from fastapi import APIRouter, HTTPException, Request, status
from fastapi import HTTPException, Request, status
from returns.maybe import Maybe, Some
from returns.result import Failure, Success
from stapi_pydantic import (
Expand All @@ -18,6 +18,7 @@
ProductsCollection,
RootResponse,
)
from starlette.datastructures import URL

from stapi_fastapi.backends.root_backend import (
GetOpportunitySearchRecord,
Expand All @@ -32,6 +33,7 @@
from stapi_fastapi.errors import NotFoundError
from stapi_fastapi.models.product import Product
from stapi_fastapi.responses import GeoJSONResponse
from stapi_fastapi.routers.base import StapiFastapiBaseRouter
from stapi_fastapi.routers.product_router import ProductRouter
from stapi_fastapi.routers.route_names import (
CONFORMANCE,
Expand All @@ -48,7 +50,7 @@
logger = logging.getLogger(__name__)


class RootRouter(APIRouter):
class RootRouter(StapiFastapiBaseRouter):
def __init__(
self,
get_orders: GetOrders,
Expand Down Expand Up @@ -170,10 +172,6 @@ def __init__(

self.conformances = list(_conformances)

@staticmethod
def url_for(request: Request, name: str, /, **path_params: Any) -> str:
return str(request.url_for(name, **path_params))

def get_root(self, request: Request) -> RootResponse:
links = [
Link(
Expand Down Expand Up @@ -245,7 +243,7 @@ def get_products(self, request: Request, next: str | None = None, limit: int = 1
),
]
if end > 0 and end < len(self.product_ids):
links.append(self.pagination_link(request, self.product_ids[end], limit))
links.append(self.pagination_link(request, f"{self.name}:{LIST_PRODUCTS}", self.product_ids[end], limit))
return ProductsCollection(
products=[self.product_routers[product_id].get_product(request) for product_id in ids],
links=links,
Expand All @@ -261,8 +259,8 @@ async def get_orders( # noqa: C901
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 Some(next_):
links.append(self.pagination_link(request, f"{self.name}:{LIST_ORDERS}", next_, limit))
case Maybe.empty:
pass
match maybe_orders_count:
Expand Down Expand Up @@ -325,8 +323,12 @@ async def get_order_statuses(
case Success(Some((statuses, maybe_pagination_token))):
links.append(self.order_statuses_link(request, order_id))
match maybe_pagination_token:
case Some(x):
links.append(self.pagination_link(request, x, limit))
case Some(next_):
links.append(
self.pagination_link(
request, f"{self.name}:{LIST_ORDER_STATUSES}", next_, limit, order_id=order_id
)
)
case Maybe.empty:
pass
case Success(Maybe.empty):
Expand All @@ -353,10 +355,10 @@ def add_product(self, product: Product, *args: Any, **kwargs: Any) -> None:
self.product_routers[product.id] = product_router
self.product_ids = [*self.product_routers.keys()]

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

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

def order_links(self, order: Order[OrderStatus], request: Request) -> list[Link]:
Expand Down Expand Up @@ -384,13 +386,9 @@ def order_statuses_link(self, request: Request, order_id: str) -> Link:
type=TYPE_JSON,
)

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
)

def pagination_link(self, request: Request, name: str, pagination_token: str, limit: int, **kwargs: Any) -> Link:
return Link(
href=href,
href=self.url_for(request, name, **kwargs).include_query_params(next=pagination_token, limit=limit),
rel="next",
type=TYPE_JSON,
)
Expand All @@ -404,8 +402,12 @@ async def get_opportunity_search_records(
for record in records:
record.links.append(self.opportunity_search_record_self_link(record, request))
match maybe_pagination_token:
case Some(x):
links.append(self.pagination_link(request, x, limit))
case Some(next_):
links.append(
self.pagination_link(
request, f"{self.name}:{LIST_OPPORTUNITY_SEARCH_RECORDS}", next_, limit
)
)
case Maybe.empty:
pass
case Failure(ValueError()):
Expand Down Expand Up @@ -470,7 +472,7 @@ async def get_opportunity_search_record_statuses(
case _:
raise AssertionError("Expected code to be unreachable")

def generate_opportunity_search_record_href(self, request: Request, search_record_id: str) -> str:
def generate_opportunity_search_record_href(self, request: Request, search_record_id: str) -> URL:
return self.url_for(
request,
f"{self.name}:{GET_OPPORTUNITY_SEARCH_RECORD}",
Expand Down
5 changes: 2 additions & 3 deletions stapi-pydantic/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

- pydantic >= 2.12 is now required.

### Added
### Changed

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

## [0.0.4] - 2025-07-17

Expand Down
5 changes: 3 additions & 2 deletions stapi-pydantic/src/stapi_pydantic/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
SerializerFunctionWrapHandler,
model_serializer,
)
from starlette.datastructures import URL
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this make starlette a new dependency of stapi-pydantic? It seems like we should avoid adding a dependency just to get a type. Normally I'd use a protocol for this, but everything in python supports __str__ so it doesn't really seem like something we can check for (hence my previous suggestion, not that I'm attached to the other code, just that was my justification for not using the URL type).



class Link(BaseModel):
Expand All @@ -22,8 +23,8 @@ class Link(BaseModel):

# redefining init is a hack to get str type to validate for `href`,
# as str is ultimately coerced into an AnyUrl automatically anyway
def __init__(self, href: AnyUrl | str, **kwargs: Any) -> None:
super().__init__(href=href, **kwargs)
def __init__(self, href: AnyUrl | URL | str, **kwargs: Any) -> None:
super().__init__(href=str(href), **kwargs)

# overriding the default serialization to filter None field values from
# dumped json
Expand Down