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

Commit 6e0285d

Browse files
committed
various cleanup and refinements
1 parent f79a589 commit 6e0285d

File tree

12 files changed

+157
-121
lines changed

12 files changed

+157
-121
lines changed

src/stapi_fastapi/__init__.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from .backends import ProductBackend, RootBackend
2+
from .models import (
3+
Link,
4+
OpportunityPropertiesBase,
5+
Product,
6+
Provider,
7+
ProviderRole,
8+
)
9+
from .routers import ProductRouter, RootRouter
10+
11+
__all__ = [
12+
"Link",
13+
"OpportunityPropertiesBase",
14+
"Product",
15+
"ProductBackend",
16+
"ProductRouter",
17+
"Provider",
18+
"ProviderRole",
19+
"RootBackend",
20+
"RootRouter",
21+
]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from .product_backend import ProductBackend
2+
from .root_backend import RootBackend
3+
4+
__all__ = [
5+
"ProductBackend",
6+
"RootBackend",
7+
]

src/stapi_fastapi/backends/product_backend.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44

55
from fastapi import Request
66

7-
import stapi_fastapi
87
from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest
98
from stapi_fastapi.models.order import Order
9+
from stapi_fastapi.models.product import Product
1010

1111

1212
class ProductBackend(Protocol):
1313
async def search_opportunities(
1414
self,
15-
product: stapi_fastapi.models.product.Product,
15+
product: Product,
1616
search: OpportunityRequest,
1717
request: Request,
1818
) -> list[Opportunity]:
@@ -26,7 +26,7 @@ async def search_opportunities(
2626

2727
async def create_order(
2828
self,
29-
product: stapi_fastapi.models.product.Product,
29+
product: Product,
3030
search: OpportunityRequest,
3131
request: Request,
3232
) -> Order:
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from .opportunity import OpportunityPropertiesBase
2+
from .product import Product, Provider, ProviderRole
3+
from .shared import Link
4+
5+
__all__ = [
6+
"Link",
7+
"OpportunityPropertiesBase",
8+
"Product",
9+
"Provider",
10+
"ProviderRole",
11+
]
Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Literal, Optional
1+
from typing import Literal, TypeVar
22

33
from geojson_pydantic import Feature, FeatureCollection
44
from geojson_pydantic.geometries import Geometry
@@ -10,7 +10,7 @@
1010

1111

1212
# Copied and modified from https://github.com/stac-utils/stac-pydantic/blob/main/stac_pydantic/item.py#L11
13-
class OpportunityProperties(BaseModel):
13+
class OpportunityPropertiesBase(BaseModel):
1414
datetime: DatetimeInterval
1515
model_config = ConfigDict(extra="allow")
1616

@@ -19,15 +19,18 @@ class OpportunityRequest(BaseModel):
1919
datetime: DatetimeInterval
2020
geometry: Geometry
2121
# TODO: validate the CQL2 filter?
22-
filter: Optional[CQL2Filter] = None
22+
filter: CQL2Filter | None = None
2323
model_config = ConfigDict(strict=True)
2424

2525

26-
# GENERIC: Each product needs an opportunity model (constraints/parameters)
27-
class Opportunity(Feature[Geometry, OpportunityProperties]):
26+
G = TypeVar("G", bound=Geometry)
27+
P = TypeVar("P", bound=OpportunityPropertiesBase)
28+
29+
30+
class Opportunity(Feature[G, P]):
2831
type: Literal["Feature"] = "Feature"
2932
links: list[Link] = []
3033

3134

32-
class OpportunityCollection(FeatureCollection[Opportunity]):
35+
class OpportunityCollection(FeatureCollection[Opportunity[G, P]]):
3336
type: Literal["FeatureCollection"] = "FeatureCollection"

src/stapi_fastapi/models/order.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
1-
from typing import Literal
1+
from typing import Literal, TypeVar
22

33
from geojson_pydantic import Feature
44
from geojson_pydantic.geometries import Geometry
5+
from pydantic import StrictInt, StrictStr
56

6-
from stapi_fastapi.models.opportunity import OpportunityProperties
7+
from stapi_fastapi.models.opportunity import OpportunityPropertiesBase
78
from stapi_fastapi.models.shared import Link
89

10+
G = TypeVar("G", bound=Geometry)
11+
P = TypeVar("P", bound=OpportunityPropertiesBase)
912

10-
class Order(Feature[Geometry, OpportunityProperties]):
13+
14+
class Order(Feature[G, P]):
15+
# We need to enforce that orders have an id defined, as that is required to
16+
# retrieve them via the API
17+
id: StrictInt | StrictStr # type: ignore
1118
type: Literal["Feature"] = "Feature"
1219
links: list[Link]

src/stapi_fastapi/models/product.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22

33
from copy import deepcopy
44
from enum import Enum
5-
from typing import Literal, Optional, Self
5+
from typing import TYPE_CHECKING, Literal, Optional, Self
66

77
from pydantic import AnyHttpUrl, BaseModel, Field
88

9-
from stapi_fastapi.backends.product_backend import ProductBackend
10-
from stapi_fastapi.models.opportunity import OpportunityProperties
9+
from stapi_fastapi.models.opportunity import OpportunityPropertiesBase
1110
from stapi_fastapi.models.shared import Link
1211

12+
if TYPE_CHECKING:
13+
from stapi_fastapi.backends.product_backend import ProductBackend
14+
1315

1416
class ProviderRole(str, Enum):
1517
licensor = "licensor"
@@ -24,6 +26,11 @@ class Provider(BaseModel):
2426
roles: list[ProviderRole]
2527
url: AnyHttpUrl
2628

29+
# redefining init is a hack to get str type to validate for `url`,
30+
# as str is ultimately coerced into an AnyHttpUrl automatically anyway
31+
def __init__(self, url: AnyHttpUrl | str, **kwargs):
32+
super().__init__(url=url, **kwargs)
33+
2734

2835
class Product(BaseModel):
2936
type_: Literal["Product"] = Field(default="Product", alias="type")
@@ -37,14 +44,14 @@ class Product(BaseModel):
3744
links: list[Link]
3845

3946
# we don't want to include these in the model fields
40-
_constraints: type[OpportunityProperties]
47+
_constraints: type[OpportunityPropertiesBase]
4148
_backend: ProductBackend
4249

4350
def __init__(
44-
self: Self,
51+
self,
4552
*args,
4653
backend: ProductBackend,
47-
constraints: type[OpportunityProperties],
54+
constraints: type[OpportunityPropertiesBase],
4855
**kwargs,
4956
) -> None:
5057
super().__init__(*args, **kwargs)
@@ -56,7 +63,7 @@ def backend(self: Self) -> ProductBackend:
5663
return self._backend
5764

5865
@property
59-
def constraints(self: Self) -> type[OpportunityProperties]:
66+
def constraints(self: Self) -> type[OpportunityPropertiesBase]:
6067
return self._constraints
6168

6269
def with_links(self: Self, links: list[Link] | None = None) -> Self:

src/stapi_fastapi/models/shared.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ class Link(BaseModel):
1414

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

17-
def model_dump_json(self: Self, *args, **kwargs) -> bytes:
17+
# redefining init is a hack to get str type to validate for `href`,
18+
# as str is ultimately coerced into an AnyUrl automatically anyway
19+
def __init__(self, href: AnyUrl | str, **kwargs):
20+
super().__init__(href=href, **kwargs)
21+
22+
def model_dump_json(self: Self, *args, **kwargs) -> str:
1823
# TODO: this isn't working as expected and we get nulls in the output
1924
# maybe need to override python dump too
2025
# forcing the call to model_dump_json to exclude unset fields by default
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from .product_router import ProductRouter
2+
from .root_router import RootRouter
3+
4+
__all__ = [
5+
"ProductRouter",
6+
"RootRouter",
7+
]

src/stapi_fastapi/routers/product_router.py

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,32 @@
1-
# Generic product router factory
21
from __future__ import annotations
32

4-
from typing import Self
3+
from typing import TYPE_CHECKING, Self
54

65
from fastapi import APIRouter, HTTPException, Request, status
7-
from fastapi.encoders import jsonable_encoder
8-
from fastapi.responses import JSONResponse
96

10-
import stapi_fastapi
117
from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON
128
from stapi_fastapi.exceptions import ConstraintsException
139
from stapi_fastapi.models.opportunity import (
1410
OpportunityCollection,
1511
OpportunityRequest,
1612
)
13+
from stapi_fastapi.models.order import Order
1714
from stapi_fastapi.models.product import Product
1815
from stapi_fastapi.models.shared import Link
1916
from stapi_fastapi.types.json_schema_model import JsonSchemaModel
2017

21-
"""
22-
/products[MainRouter]/opportunities
23-
/products[MainRouter]/parameters
24-
/products[MainRouter]/order
25-
"""
18+
if TYPE_CHECKING:
19+
from stapi_fastapi.routers import RootRouter
2620

2721

2822
class ProductRouter(APIRouter):
2923
def __init__(
30-
self: Self,
24+
self,
3125
product: Product,
32-
root_router: stapi_fastapi.routers.RootRouter,
26+
root_router: RootRouter,
3327
*args,
3428
**kwargs,
35-
):
29+
) -> None:
3630
super().__init__(*args, **kwargs)
3731
self.product = product
3832
self.root_router = root_router
@@ -50,6 +44,13 @@ def __init__(
5044
endpoint=self.search_opportunities,
5145
name=f"{self.root_router.name}:{self.product.id}:search-opportunities",
5246
methods=["POST"],
47+
responses={
48+
200: {
49+
"content": {
50+
"TYPE_GEOJSON": {},
51+
},
52+
}
53+
},
5354
summary="Search Opportunities for the product",
5455
)
5556

@@ -66,6 +67,13 @@ def __init__(
6667
endpoint=self.create_order,
6768
name=f"{self.root_router.name}:{self.product.id}:create-order",
6869
methods=["POST"],
70+
responses={
71+
201: {
72+
"content": {
73+
"TYPE_GEOJSON": {},
74+
},
75+
}
76+
},
6977
summary="Create an order for the product",
7078
)
7179

@@ -92,37 +100,33 @@ async def search_opportunities(
92100
"""
93101
try:
94102
opportunities = await self.product.backend.search_opportunities(
95-
search, request
103+
self.product, search, request
96104
)
97105
except ConstraintsException as exc:
98106
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-
)
107+
return OpportunityCollection(features=opportunities)
103108

104-
async def get_product_constraints(self: Self, request: Request) -> JsonSchemaModel:
109+
async def get_product_constraints(self: Self) -> JsonSchemaModel:
105110
"""
106111
Return supported constraints of a specific product
107112
"""
108113
return self.product.constraints
109114

110115
async def create_order(
111116
self, payload: OpportunityRequest, request: Request
112-
) -> JSONResponse:
117+
) -> Order:
113118
"""
114119
Create a new order.
115120
"""
116121
try:
117-
order = await self.product.backend.create_order(payload, request)
122+
order = await self.product.backend.create_order(
123+
self.product,
124+
payload,
125+
request,
126+
)
118127
except ConstraintsException as exc:
119128
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail)
120129

121130
location = self.root_router.generate_order_href(request, order.id)
122-
order.links.append(Link(href=location, rel="self", type=TYPE_GEOJSON))
123-
return JSONResponse(
124-
jsonable_encoder(order, exclude_unset=True),
125-
status.HTTP_201_CREATED,
126-
{"Location": location},
127-
TYPE_GEOJSON,
128-
)
131+
order.links.append(Link(href=str(location), rel="self", type=TYPE_GEOJSON))
132+
return order

0 commit comments

Comments
 (0)