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

Commit 3139ec4

Browse files
overhaul the main router as APIRouter, product router not a factory, add product router classmethod to main router
1 parent c0d7a23 commit 3139ec4

File tree

6 files changed

+179
-164
lines changed

6 files changed

+179
-164
lines changed

conftest.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1-
from typing import Callable, Generator, TypeVar
1+
from typing import Callable, Generator, TypeVar, List
22
from urllib.parse import urljoin
3+
from uuid import uuid4
4+
import pytest
5+
from datetime import datetime, timezone, timedelta
6+
from geojson_pydantic import Point
37

48
from fastapi import FastAPI
59
from fastapi.testclient import TestClient
610
from pytest import Parser, fixture
711

12+
from stapi_fastapi.models.product import Product, Provider, ProviderRole
813
from stapi_fastapi.main_router import MainRouter
914
from stapi_fastapi_test_backend import TestBackend
15+
from stapi_fastapi.models.shared import Link
16+
from stapi_fastapi.models.opportunity import OpportunityProperties, Opportunity
17+
from stapi_fastapi.types.datetime_interval import DatetimeInterval
1018

1119
T = TypeVar("T")
1220

@@ -54,3 +62,60 @@ def url_for(value: str) -> str:
5462
return urljoin(with_trailing_slash(base_url), f"./{value.lstrip('/')}")
5563

5664
yield url_for
65+
66+
@pytest.fixture
67+
def mock_provider_umbra() -> Provider:
68+
return Provider(
69+
name="Umbra Provider",
70+
description="A provider for Umbra data",
71+
roles=[ProviderRole.producer], # Example role
72+
url="https://umbra-provider.example.com" # Must be a valid URL
73+
)
74+
75+
# Define a mock OpportunityProperties class for Umbra
76+
class UmbraSpotlightProperties(OpportunityProperties):
77+
datetime: DatetimeInterval
78+
79+
@pytest.fixture
80+
def mock_product_umbra_spotlight(mock_provider_umbra: Provider) -> Product:
81+
"""Fixture for a mock Umbra Spotlight product."""
82+
83+
return Product(
84+
id=str(uuid4()),
85+
title="Umbra Spotlight Product",
86+
description="Test product for umbra spotlight",
87+
license="CC-BY-4.0",
88+
keywords=["test", "umbra", "satellite"],
89+
providers=[mock_provider_umbra],
90+
links=[
91+
Link(href="http://example.com", rel="self"),
92+
Link(href="http://example.com/catalog", rel="parent"),
93+
],
94+
parameters=UmbraSpotlightProperties
95+
)
96+
97+
@pytest.fixture
98+
def mock_products(mock_product_umbra_spotlight: Product) -> List[Product]:
99+
"""Fixture to return a list of mock products."""
100+
return [mock_product_umbra_spotlight]
101+
102+
@pytest.fixture
103+
def mock_umbra_spotlight_opportunities() -> List[Opportunity]:
104+
"""Fixture to create mock data for Opportunities for `umbra-spotlight-1`."""
105+
now = datetime.now(timezone.utc) # Use timezone-aware datetime
106+
start = now
107+
end = start + timedelta(days=5)
108+
datetime_interval = f"{start.isoformat()}/{end.isoformat()}"
109+
110+
# Create a list of mock opportunities for the given product
111+
return [
112+
Opportunity(
113+
id=str(uuid4()),
114+
type="Feature",
115+
geometry=Point(type="Point", coordinates=[0, 0]), # Simple point geometry
116+
properties=UmbraSpotlightProperties(
117+
datetime=datetime_interval,
118+
off_nadir=20,
119+
),
120+
),
121+
]

stapi_fastapi/main_router.py

Lines changed: 26 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from typing import Self, Optional
12
from fastapi import APIRouter, HTTPException, Request, status
23
from fastapi.encoders import jsonable_encoder
34
from fastapi.responses import JSONResponse
@@ -14,91 +15,65 @@
1415
from stapi_fastapi.models.root import RootResponse
1516
from stapi_fastapi.models.shared import HTTPException as HTTPExceptionModel
1617
from stapi_fastapi.models.shared import Link
18+
from stapi_fastapi.products_router import ProductRouter
1719

1820
"""
1921
/products/{component router} # router for each product added to main router
2022
/orders # list all orders
2123
"""
22-
class MainRouter:
23-
NAME_PREFIX = "main"
24-
backend: StapiBackend
25-
openapi_endpoint_name: str
26-
docs_endpoint_name: str
27-
router: APIRouter
24+
class MainRouter(APIRouter):
2825

2926
def __init__(
30-
self,
27+
self: Self,
3128
backend: StapiBackend,
32-
openapi_endpoint_name="openapi",
33-
docs_endpoint_name="swagger_ui_html",
29+
name: str = "main",
30+
openapi_endpoint_name: str = "openapi",
31+
docs_endpoint_name: str = "swagger_ui_html",
3432
*args,
3533
**kwargs,
3634
):
35+
super().__init__(*args, **kwargs)
3736
self.backend = backend
37+
self.name = name
3838
self.openapi_endpoint_name = openapi_endpoint_name
3939
self.docs_endpoint_name = docs_endpoint_name
4040

41-
self.router = APIRouter(*args, **kwargs)
42-
self.router.add_api_route(
41+
self.product_routers: dict[str, ProductRouter] = {}
42+
43+
self.add_api_route(
4344
"/",
4445
self.root,
4546
methods=["GET"],
46-
name=f"{self.NAME_PREFIX}:root",
47+
name=f"{self.name}:root",
4748
tags=["Root"],
4849
)
4950

50-
self.router.add_api_route(
51+
self.add_api_route(
5152
"/products",
5253
self.products,
5354
methods=["GET"],
54-
name=f"{self.NAME_PREFIX}:list-products",
55-
tags=["Product"],
56-
)
57-
58-
self.router.add_api_route(
59-
"/products/{product_id}",
60-
self.product,
61-
methods=["GET"],
62-
name=f"{self.NAME_PREFIX}:get-product",
55+
name=f"{self.name}:list-products",
6356
tags=["Product"],
64-
responses={status.HTTP_404_NOT_FOUND: {"model": HTTPExceptionModel}},
6557
)
6658

67-
self.router.add_api_route(
68-
"/opportunities",
69-
self.search_opportunities,
70-
methods=["POST"],
71-
name=f"{self.NAME_PREFIX}:search-opportunities",
72-
tags=["Opportunities"],
73-
)
74-
75-
self.router.add_api_route(
76-
"/orders",
77-
self.create_order,
78-
methods=["POST"],
79-
name=f"{self.NAME_PREFIX}:create-order",
80-
tags=["Orders"],
81-
response_model=Order,
82-
)
83-
84-
self.router.add_api_route(
59+
self.add_api_route(
8560
"/orders/{order_id}",
8661
self.get_order,
8762
methods=["GET"],
88-
name=f"{self.NAME_PREFIX}:get-order",
63+
name=f"{self.name}:get-order",
8964
tags=["Orders"],
9065
)
9166

9267
def root(self, request: Request) -> RootResponse:
9368
return RootResponse(
9469
links=[
9570
Link(
96-
href=str(request.url_for(f"{self.NAME_PREFIX}:root")),
71+
href=str(request.url_for(f"{self.name}:root")),
9772
rel="self",
9873
type=TYPE_JSON,
9974
),
10075
Link(
101-
href=str(request.url_for(f"{self.NAME_PREFIX}:list-products")),
76+
href=str(request.url_for(f"{self.name}:list-products")),
10277
rel="products",
10378
type=TYPE_JSON,
10479
),
@@ -122,7 +97,7 @@ def products(self, request: Request) -> ProductsCollection:
12297
Link(
12398
href=str(
12499
request.url_for(
125-
f"{self.NAME_PREFIX}:get-product", product_id=product.id
100+
f"{self.name}:get-product", product_id=product.id
126101
)
127102
),
128103
rel="self",
@@ -133,70 +108,13 @@ def products(self, request: Request) -> ProductsCollection:
133108
products=products,
134109
links=[
135110
Link(
136-
href=str(request.url_for(f"{self.NAME_PREFIX}:list-products")),
111+
href=str(request.url_for(f"{self.name}:list-products")),
137112
rel="self",
138113
type=TYPE_JSON,
139114
)
140115
],
141116
)
142117

143-
def product(self, product_id: str, request: Request) -> Product:
144-
try:
145-
product = self.backend.product(product_id, request)
146-
except NotFoundException as exc:
147-
raise StapiException(
148-
status.HTTP_404_NOT_FOUND, "product not found"
149-
) from exc
150-
product.links.append(
151-
Link(
152-
href=str(
153-
request.url_for(
154-
f"{self.NAME_PREFIX}:get-product", product_id=product.id
155-
)
156-
),
157-
rel="self",
158-
type=TYPE_JSON,
159-
)
160-
)
161-
return product
162-
163-
async def search_opportunities(
164-
self, search: OpportunityRequest, request: Request
165-
) -> OpportunityCollection:
166-
"""
167-
Explore the opportunities available for a particular set of constraints
168-
"""
169-
try:
170-
opportunities = await self.backend.search_opportunities(search, request)
171-
except ConstraintsException as exc:
172-
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail)
173-
return JSONResponse(
174-
jsonable_encoder(OpportunityCollection(features=opportunities)),
175-
media_type=TYPE_GEOJSON,
176-
)
177-
178-
async def create_order(
179-
self, search: OpportunityRequest, request: Request
180-
) -> JSONResponse:
181-
"""
182-
Create a new order.
183-
"""
184-
try:
185-
order = await self.backend.create_order(search, request)
186-
except ConstraintsException as exc:
187-
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail)
188-
189-
location = str(
190-
request.url_for(f"{self.NAME_PREFIX}:get-order", order_id=order.id)
191-
)
192-
order.links.append(Link(href=location, rel="self", type=TYPE_GEOJSON))
193-
return JSONResponse(
194-
jsonable_encoder(order, exclude_unset=True),
195-
status.HTTP_201_CREATED,
196-
{"Location": location},
197-
TYPE_GEOJSON,
198-
)
199-
200118
async def get_order(self, order_id: str, request: Request) -> Order:
201119
"""
202120
Get details for order with `order_id`.
@@ -213,3 +131,8 @@ async def get_order(self, order_id: str, request: Request) -> Order:
213131
status.HTTP_200_OK,
214132
media_type=TYPE_GEOJSON,
215133
)
134+
135+
def add_product_router(self, product_router: ProductRouter):
136+
# Give the include a prefix from the product router
137+
self.include_router(product_router, prefix=product_router.product.id)
138+
self.product_routers[product_router.product.id] = product_router

stapi_fastapi/models/opportunity.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class OpportunityRequest(BaseModel):
2020
geometry: Geometry
2121
# TODO: validate the CQL2 filter?
2222
filter: Optional[CQL2Filter] = None
23-
# PHILOSOPH: strict?
23+
model_config = ConfigDict(strict=True)
2424

2525
# Generic type definition for Opportunity
2626
P = TypeVar("P", bound=OpportunityProperties)
@@ -30,6 +30,5 @@ class OpportunityRequest(BaseModel):
3030
class Opportunity(Feature[K, P], Generic[K, P]):
3131
type: Literal["Feature"] = "Feature"
3232

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

stapi_fastapi/models/product.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
from enum import Enum
22
from typing import Literal, Optional
3+
from abc import ABC, abstractmethod
4+
from fastapi import Request
35

46
from pydantic import AnyHttpUrl, BaseModel, Field
57

68
from stapi_fastapi.models.shared import Link
7-
from stapi_fastapi.types.json_schema_model import JsonSchemaModel
9+
from stapi_fastapi.models.opportunity import Opportunity, OpportunityProperties, OpportunityRequest
10+
from stapi_fastapi.models.order import Order
811

912

1013
class ProviderRole(str, Enum):
@@ -21,7 +24,7 @@ class Provider(BaseModel):
2124
url: AnyHttpUrl
2225

2326

24-
class Product(BaseModel):
27+
class Product(BaseModel, ABC):
2528
type: Literal["Product"] = "Product"
2629
conformsTo: list[str] = Field(default_factory=list)
2730
id: str
@@ -31,8 +34,28 @@ class Product(BaseModel):
3134
license: str
3235
providers: list[Provider] = Field(default_factory=list)
3336
links: list[Link]
34-
parameters: JsonSchemaModel
35-
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+
...
3659

3760
class ProductsCollection(BaseModel):
3861
type: Literal["ProductCollection"] = "ProductCollection"

0 commit comments

Comments
 (0)