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

Commit 1adf323

Browse files
committed
Merge branch 'mp/feat-subclass-router-issue-73' into feat-subclass-router-issue-73
2 parents 6fa822b + 91f8b65 commit 1adf323

19 files changed

+553
-235
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ description = "Spatio Temporal Asset Tasking with FastAPI"
66
authors = ["Christian Wygoda <[email protected]>"]
77
license = "MIT"
88
readme = "README.md"
9+
packages = [{include = "stapi_fastapi", from="src"}]
910

1011
[tool.poetry.dependencies]
1112
python = "3.12.*"

src/stapi_fastapi/backends/__init__.py

Whitespace-only changes.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from __future__ import annotations
2+
3+
from typing import Protocol
4+
5+
from fastapi import Request
6+
7+
import stapi_fastapi
8+
from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest
9+
from stapi_fastapi.models.order import Order
10+
11+
12+
class ProductBackend(Protocol):
13+
async def search_opportunities(
14+
self,
15+
product: stapi_fastapi.models.product.Product,
16+
search: OpportunityRequest,
17+
request: Request,
18+
) -> list[Opportunity]:
19+
"""
20+
Search for ordering opportunities for the given search parameters.
21+
22+
Backends must validate search constraints and raise
23+
`stapi_fastapi.backend.exceptions.ConstraintsException` if not valid.
24+
"""
25+
...
26+
27+
async def create_order(
28+
self,
29+
product: stapi_fastapi.models.product.Product,
30+
search: OpportunityRequest,
31+
request: Request,
32+
) -> Order:
33+
"""
34+
Create a new order.
35+
36+
Backends must validate order payload and raise
37+
`stapi_fastapi.backend.exceptions.ConstraintsException` if not valid.
38+
"""
39+
...
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from typing import Protocol
2+
3+
from fastapi import Request
4+
5+
from stapi_fastapi.models.order import Order
6+
7+
8+
class RootBackend(Protocol):
9+
async def get_orders(self, request: Request) -> list[Order]:
10+
"""
11+
Return a list of existing orders.
12+
"""
13+
...
14+
15+
async def get_order(self, order_id: str, request: Request) -> Order:
16+
"""
17+
Get details for order with `order_id`.
18+
19+
Backends must raise `stapi_fastapi.backend.exceptions.NotFoundException`
20+
if not found or access denied.
21+
"""
22+
...

src/stapi_fastapi/exceptions.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
from typing import Any, Mapping
2+
23
from fastapi import HTTPException
34

5+
46
class StapiException(HTTPException):
57
def __init__(self, status_code: int, detail: str) -> None:
68
super().__init__(status_code, detail)
79

10+
811
class ConstraintsException(Exception):
912
detail: Mapping[str, Any] | None
1013

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,33 @@
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
55
from pydantic import BaseModel, ConfigDict
66

7+
from stapi_fastapi.models.shared import Link
78
from stapi_fastapi.types.datetime_interval import DatetimeInterval
89
from stapi_fastapi.types.filter import CQL2Filter
910

10-
# Generic type definition for Opportunity Properties
11-
T = TypeVar("T")
1211

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

17+
1818
class OpportunityRequest(BaseModel):
1919
datetime: DatetimeInterval
2020
geometry: Geometry
2121
# TODO: validate the CQL2 filter?
2222
filter: Optional[CQL2Filter] = None
2323
model_config = ConfigDict(strict=True)
2424

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

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

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

src/stapi_fastapi/models/product.py

Lines changed: 40 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1+
from __future__ import annotations
2+
3+
from copy import deepcopy
14
from enum import Enum
2-
from typing import Literal, Optional
3-
from abc import ABC, abstractmethod
4-
from fastapi import Request
5+
from typing import Literal, Optional, Self
56

67
from pydantic import AnyHttpUrl, BaseModel, Field
78

9+
from stapi_fastapi.backends.product_backend import ProductBackend
10+
from stapi_fastapi.models.opportunity import OpportunityProperties
811
from stapi_fastapi.models.shared import Link
9-
from stapi_fastapi.models.opportunity import Opportunity, OpportunityProperties, OpportunityRequest
10-
from stapi_fastapi.models.order import Order
1112

1213

1314
class ProviderRole(str, Enum):
@@ -24,8 +25,8 @@ class Provider(BaseModel):
2425
url: AnyHttpUrl
2526

2627

27-
class Product(BaseModel, ABC):
28-
type: Literal["Product"] = "Product"
28+
class Product(BaseModel):
29+
type_: Literal["Product"] = Field(default="Product", alias="type")
2930
conformsTo: list[str] = Field(default_factory=list)
3031
id: str
3132
title: str = ""
@@ -34,28 +35,38 @@ class Product(BaseModel, ABC):
3435
license: str
3536
providers: list[Provider] = Field(default_factory=list)
3637
links: list[Link]
37-
parameters: OpportunityProperties
38-
39-
@abstractmethod
40-
def search_opportunities(self, search: OpportunityRequest, request: Request
41-
) -> list[Opportunity]:
42-
"""
43-
Search for ordering opportunities for the given search parameters.
44-
45-
Backends must validate search constraints and raise
46-
`stapi_fastapi.backend.exceptions.ConstraintsException` if not valid.
47-
"""
48-
...
49-
50-
@abstractmethod
51-
def create_order(self, search: OpportunityRequest, request: Request) -> Order:
52-
"""
53-
Create a new order.
54-
55-
Backends must validate order payload and raise
56-
`stapi_fastapi.backend.exceptions.ConstraintsException` if not valid.
57-
"""
58-
...
38+
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:
50+
super().__init__(*args, **kwargs)
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
69+
5970

6071
class ProductsCollection(BaseModel):
6172
type: Literal["ProductCollection"] = "ProductCollection"

src/stapi_fastapi/models/shared.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
1-
from typing import Optional
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
12+
headers: Optional[dict[str, Union[str, list[str]]]] = None
13+
body: Any = None
1214

1315
model_config = ConfigDict(extra="allow")
1416

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+
1524

1625
class HTTPException(BaseModel):
1726
detail: str

src/stapi_fastapi/routers/__init__.py

Whitespace-only changes.
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# Generic product router factory
2+
from __future__ import annotations
3+
4+
from typing import Self
5+
6+
from fastapi import APIRouter, HTTPException, Request, status
7+
from fastapi.encoders import jsonable_encoder
8+
from fastapi.responses import JSONResponse
9+
10+
import stapi_fastapi
11+
from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON
12+
from stapi_fastapi.exceptions import ConstraintsException
13+
from stapi_fastapi.models.opportunity import (
14+
OpportunityCollection,
15+
OpportunityRequest,
16+
)
17+
from stapi_fastapi.models.product import Product
18+
from stapi_fastapi.models.shared import Link
19+
from stapi_fastapi.types.json_schema_model import JsonSchemaModel
20+
21+
"""
22+
/products[MainRouter]/opportunities
23+
/products[MainRouter]/parameters
24+
/products[MainRouter]/order
25+
"""
26+
27+
28+
class ProductRouter(APIRouter):
29+
def __init__(
30+
self: Self,
31+
product: Product,
32+
root_router: stapi_fastapi.routers.RootRouter,
33+
*args,
34+
**kwargs,
35+
):
36+
super().__init__(*args, **kwargs)
37+
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+
)
47+
48+
self.add_api_route(
49+
path="/opportunities",
50+
endpoint=self.search_opportunities,
51+
name=f"{self.root_router.name}:{self.product.id}:search-opportunities",
52+
methods=["POST"],
53+
summary="Search Opportunities for the product",
54+
)
55+
56+
self.add_api_route(
57+
path="/constraints",
58+
endpoint=self.get_product_constraints,
59+
name=f"{self.root_router.name}:{self.product.id}:get-constraints",
60+
methods=["GET"],
61+
summary="Get constraints for the product",
62+
)
63+
64+
self.add_api_route(
65+
path="/order",
66+
endpoint=self.create_order,
67+
name=f"{self.root_router.name}:{self.product.id}:create-order",
68+
methods=["POST"],
69+
summary="Create an order for the product",
70+
)
71+
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+
87+
async def search_opportunities(
88+
self, search: OpportunityRequest, request: Request
89+
) -> OpportunityCollection:
90+
"""
91+
Explore the opportunities available for a particular set of constraints
92+
"""
93+
try:
94+
opportunities = await self.product.backend.search_opportunities(
95+
search, request
96+
)
97+
except ConstraintsException as exc:
98+
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail)
99+
return JSONResponse(
100+
jsonable_encoder(OpportunityCollection(features=opportunities)),
101+
media_type=TYPE_GEOJSON,
102+
)
103+
104+
async def get_product_constraints(self: Self, request: Request) -> JsonSchemaModel:
105+
"""
106+
Return supported constraints of a specific product
107+
"""
108+
return {
109+
"product.id": self.product.product.id,
110+
"constraints": self.product.constraints,
111+
}
112+
113+
async def create_order(
114+
self, payload: OpportunityRequest, request: Request
115+
) -> JSONResponse:
116+
"""
117+
Create a new order.
118+
"""
119+
try:
120+
order = await self.product.backend.create_order(payload, request)
121+
except ConstraintsException as exc:
122+
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail)
123+
124+
location = self.root_router.generate_order_href(request, order.id)
125+
order.links.append(Link(href=location, rel="self", type=TYPE_GEOJSON))
126+
return JSONResponse(
127+
jsonable_encoder(order, exclude_unset=True),
128+
status.HTTP_201_CREATED,
129+
{"Location": location},
130+
TYPE_GEOJSON,
131+
)

0 commit comments

Comments
 (0)