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

Commit 91f8b65

Browse files
parksjrjkeifer
andcommitted
wip: splits backend and router for root and product.
Co-authored-by: Jarrett Keifer <[email protected]>
1 parent 571f86a commit 91f8b65

File tree

15 files changed

+277
-275
lines changed

15 files changed

+277
-275
lines changed
Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,39 @@
1+
from __future__ import annotations
2+
13
from typing import Protocol
24

35
from fastapi import Request
46

7+
import stapi_fastapi
58
from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest
69
from stapi_fastapi.models.order import Order
7-
from stapi_fastapi.models.product import Product
810

911

1012
class ProductBackend(Protocol):
11-
def product(self, product_id: str, request: Request) -> Product | None:
12-
"""
13-
Return the product identified by `product_id` or `None` if it isn't
14-
supported.
15-
"""
16-
1713
async def search_opportunities(
18-
self, product_id: str, search: OpportunityRequest, request: Request
14+
self,
15+
product: stapi_fastapi.models.product.Product,
16+
search: OpportunityRequest,
17+
request: Request,
1918
) -> list[Opportunity]:
2019
"""
2120
Search for ordering opportunities for the given search parameters.
2221
2322
Backends must validate search constraints and raise
2423
`stapi_fastapi.backend.exceptions.ConstraintsException` if not valid.
2524
"""
25+
...
2626

2727
async def create_order(
28-
self, product_id: str, search: OpportunityRequest, request: Request
28+
self,
29+
product: stapi_fastapi.models.product.Product,
30+
search: OpportunityRequest,
31+
request: Request,
2932
) -> Order:
3033
"""
3134
Create a new order.
3235
3336
Backends must validate order payload and raise
3437
`stapi_fastapi.backend.exceptions.ConstraintsException` if not valid.
3538
"""
39+
...

src/stapi_fastapi/backends/root_backend.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,14 @@
33
from fastapi import Request
44

55
from stapi_fastapi.models.order import Order
6-
from stapi_fastapi.models.product import Product
76

87

98
class RootBackend(Protocol):
10-
def products(self, request: Request) -> list[Product]:
11-
"""
12-
Return a list of supported products.
13-
"""
14-
15-
def orders(self, request: Request) -> list[Order]:
9+
async def get_orders(self, request: Request) -> list[Order]:
1610
"""
1711
Return a list of existing orders.
1812
"""
13+
...
1914

2015
async def get_order(self, order_id: str, request: Request) -> Order:
2116
"""
@@ -24,3 +19,4 @@ async def get_order(self, order_id: str, request: Request) -> Order:
2419
Backends must raise `stapi_fastapi.backend.exceptions.NotFoundException`
2520
if not found or access denied.
2621
"""
22+
...
Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Literal, Optional, TypeVar, Generic
1+
from typing import Literal, Optional
22

33
from geojson_pydantic import Feature, FeatureCollection
44
from geojson_pydantic.geometries import Geometry
@@ -8,29 +8,26 @@
88
from stapi_fastapi.types.datetime_interval import DatetimeInterval
99
from stapi_fastapi.types.filter import CQL2Filter
1010

11-
# Generic type definition for Opportunity Properties
12-
T = TypeVar("T")
1311

1412
# Copied and modified from https://github.com/stac-utils/stac-pydantic/blob/main/stac_pydantic/item.py#L11
15-
class OpportunityProperties(BaseModel, Generic[T]):
13+
class OpportunityProperties(BaseModel):
1614
datetime: DatetimeInterval
1715
model_config = ConfigDict(extra="allow")
1816

17+
1918
class OpportunityRequest(BaseModel):
2019
datetime: DatetimeInterval
2120
geometry: Geometry
2221
# TODO: validate the CQL2 filter?
2322
filter: Optional[CQL2Filter] = None
2423
model_config = ConfigDict(strict=True)
2524

26-
# Generic type definition for Opportunity
27-
P = TypeVar("P", bound=OpportunityProperties)
28-
K = TypeVar("K", bound=Geometry)
2925

30-
# Each product implements its own opportunity model
31-
class Opportunity(Feature[K, P], Generic[K, P]):
26+
# GENERIC: Each product needs an opportunity model (constraints/parameters)
27+
class Opportunity(Feature[Geometry, OpportunityProperties]):
3228
type: Literal["Feature"] = "Feature"
3329
links: list[Link] = []
3430

35-
class OpportunityCollection(FeatureCollection[Opportunity[K, P]], Generic[K, P]):
31+
32+
class OpportunityCollection(FeatureCollection[Opportunity]):
3633
type: Literal["FeatureCollection"] = "FeatureCollection"

src/stapi_fastapi/models/product.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
from __future__ import annotations
2+
3+
from copy import deepcopy
14
from enum import Enum
25
from typing import Literal, Optional, Self
36

47
from pydantic import AnyHttpUrl, BaseModel, Field
58

69
from stapi_fastapi.backends.product_backend import ProductBackend
10+
from stapi_fastapi.models.opportunity import OpportunityProperties
711
from stapi_fastapi.models.shared import Link
8-
from stapi_fastapi.types.json_schema_model import JsonSchemaModel
912

1013

1114
class ProviderRole(str, Enum):
@@ -23,7 +26,7 @@ class Provider(BaseModel):
2326

2427

2528
class Product(BaseModel):
26-
type: Literal["Product"] = "Product"
29+
type_: Literal["Product"] = Field(default="Product", alias="type")
2730
conformsTo: list[str] = Field(default_factory=list)
2831
id: str
2932
title: str = ""
@@ -32,11 +35,37 @@ class Product(BaseModel):
3235
license: str
3336
providers: list[Provider] = Field(default_factory=list)
3437
links: list[Link]
35-
constraints: JsonSchemaModel
3638

37-
def __init__(self: Self, backend: ProductBackend, *args, **kwargs):
39+
# we don't want to include these in the model fields
40+
_constraints: type[OpportunityProperties]
41+
_backend: ProductBackend
42+
43+
def __init__(
44+
self: Self,
45+
*args,
46+
backend: ProductBackend,
47+
constraints: type[OpportunityProperties],
48+
**kwargs,
49+
) -> None:
3850
super().__init__(*args, **kwargs)
39-
self.backend = backend
51+
self._backend = backend
52+
self._constraints = constraints
53+
54+
@property
55+
def backend(self: Self) -> ProductBackend:
56+
return self._backend
57+
58+
@property
59+
def constraints(self: Self) -> type[OpportunityProperties]:
60+
return self._constraints
61+
62+
def with_links(self: Self, links: list[Link] | None = None) -> Self:
63+
if not links:
64+
return self
65+
66+
new = deepcopy(self)
67+
new.links.extend(links)
68+
return new
4069

4170

4271
class ProductsCollection(BaseModel):

src/stapi_fastapi/models/shared.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
1-
from typing import Any, Optional, Union
1+
from typing import Any, Optional, Self, Union
22

33
from pydantic import AnyUrl, BaseModel, ConfigDict
44

55

66
class Link(BaseModel):
77
href: AnyUrl
88
rel: str
9-
type: Optional[str] = None
9+
type: str | None = None
1010
title: Optional[str] = None
1111
method: Optional[str] = None
1212
headers: Optional[dict[str, Union[str, list[str]]]] = None
1313
body: Any = None
1414

1515
model_config = ConfigDict(extra="allow")
1616

17+
def model_dump_json(self: Self, *args, **kwargs) -> bytes:
18+
# TODO: this isn't working as expected and we get nulls in the output
19+
# maybe need to override python dump too
20+
# forcing the call to model_dump_json to exclude unset fields by default
21+
kwargs["exclude_unset"] = kwargs.get("exclude_unset", True)
22+
return super().model_dump_json(*args, **kwargs)
23+
1724

1825
class HTTPException(BaseModel):
1926
detail: str

src/stapi_fastapi/routers/product_router.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
# Generic product router factory
2+
from __future__ import annotations
3+
24
from typing import Self
35

46
from fastapi import APIRouter, HTTPException, Request, status
57
from fastapi.encoders import jsonable_encoder
68
from fastapi.responses import JSONResponse
79

8-
from stapi_fastapi.constants import TYPE_GEOJSON
10+
import stapi_fastapi
11+
from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON
912
from stapi_fastapi.exceptions import ConstraintsException
1013
from stapi_fastapi.models.opportunity import (
1114
OpportunityCollection,
@@ -26,33 +29,61 @@ class ProductRouter(APIRouter):
2629
def __init__(
2730
self: Self,
2831
product: Product,
32+
root_router: stapi_fastapi.routers.RootRouter,
2933
*args,
3034
**kwargs,
3135
):
3236
super().__init__(*args, **kwargs)
3337
self.product = product
38+
self.root_router = root_router
39+
40+
self.add_api_route(
41+
path="",
42+
endpoint=self.get_product,
43+
name=f"{self.root_router.name}:{self.product.id}:get-product",
44+
methods=["GET"],
45+
summary="Retrieve this product",
46+
)
3447

3548
self.add_api_route(
3649
path="/opportunities",
3750
endpoint=self.search_opportunities,
51+
name=f"{self.root_router.name}:{self.product.id}:search-opportunities",
3852
methods=["POST"],
3953
summary="Search Opportunities for the product",
4054
)
4155

4256
self.add_api_route(
4357
path="/constraints",
4458
endpoint=self.get_product_constraints,
59+
name=f"{self.root_router.name}:{self.product.id}:get-constraints",
4560
methods=["GET"],
4661
summary="Get constraints for the product",
4762
)
4863

4964
self.add_api_route(
5065
path="/order",
5166
endpoint=self.create_order,
67+
name=f"{self.root_router.name}:{self.product.id}:create-order",
5268
methods=["POST"],
5369
summary="Create an order for the product",
5470
)
5571

72+
def get_product(self, request: Request) -> Product:
73+
return self.product.with_links(
74+
links=[
75+
Link(
76+
href=str(
77+
request.url_for(
78+
f"{self.root_router.name}:{self.product.id}:get-product",
79+
),
80+
),
81+
rel="self",
82+
type=TYPE_JSON,
83+
),
84+
],
85+
)
86+
5687
async def search_opportunities(
5788
self, search: OpportunityRequest, request: Request
5889
) -> OpportunityCollection:
@@ -75,7 +106,7 @@ async def get_product_constraints(self: Self, request: Request) -> JsonSchemaMod
75106
Return supported constraints of a specific product
76107
"""
77108
return {
78-
"product_id": self.product.product_id,
109+
"product.id": self.product.product.id,
79110
"constraints": self.product.constraints,
80111
}
81112

@@ -90,9 +121,7 @@ async def create_order(
90121
except ConstraintsException as exc:
91122
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail)
92123

93-
location = str(
94-
request.url_for(f"{self.NAME_PREFIX}:get-order", order_id=order.id)
95-
)
124+
location = self.root_router.generate_order_href(request, order.id)
96125
order.links.append(Link(href=location, rel="self", type=TYPE_GEOJSON))
97126
return JSONResponse(
98127
jsonable_encoder(order, exclude_unset=True),

0 commit comments

Comments
 (0)