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

Commit 571f86a

Browse files
committed
adds product router and backend
1 parent 1c4126f commit 571f86a

File tree

8 files changed

+339
-28
lines changed

8 files changed

+339
-28
lines changed

src/stapi_fastapi/backends/__init__.py

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

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

src/stapi_fastapi/models/product.py

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

64
from pydantic import AnyHttpUrl, BaseModel, Field
75

6+
from stapi_fastapi.backends.product_backend import ProductBackend
87
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
8+
from stapi_fastapi.types.json_schema_model import JsonSchemaModel
119

1210

1311
class ProviderRole(str, Enum):
@@ -24,7 +22,7 @@ class Provider(BaseModel):
2422
url: AnyHttpUrl
2523

2624

27-
class Product(BaseModel, ABC):
25+
class Product(BaseModel):
2826
type: Literal["Product"] = "Product"
2927
conformsTo: list[str] = Field(default_factory=list)
3028
id: str
@@ -34,28 +32,12 @@ class Product(BaseModel, ABC):
3432
license: str
3533
providers: list[Provider] = Field(default_factory=list)
3634
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-
...
35+
constraints: JsonSchemaModel
36+
37+
def __init__(self: Self, backend: ProductBackend, *args, **kwargs):
38+
super().__init__(*args, **kwargs)
39+
self.backend = backend
40+
5941

6042
class ProductsCollection(BaseModel):
6143
type: Literal["ProductCollection"] = "ProductCollection"

src/stapi_fastapi/routers/__init__.py

Whitespace-only changes.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Generic product router factory
2+
from typing import Self
3+
4+
from fastapi import APIRouter, HTTPException, Request, status
5+
from fastapi.encoders import jsonable_encoder
6+
from fastapi.responses import JSONResponse
7+
8+
from stapi_fastapi.constants import TYPE_GEOJSON
9+
from stapi_fastapi.exceptions import ConstraintsException
10+
from stapi_fastapi.models.opportunity import (
11+
OpportunityCollection,
12+
OpportunityRequest,
13+
)
14+
from stapi_fastapi.models.product import Product
15+
from stapi_fastapi.models.shared import Link
16+
from stapi_fastapi.types.json_schema_model import JsonSchemaModel
17+
18+
"""
19+
/products[MainRouter]/opportunities
20+
/products[MainRouter]/parameters
21+
/products[MainRouter]/order
22+
"""
23+
24+
25+
class ProductRouter(APIRouter):
26+
def __init__(
27+
self: Self,
28+
product: Product,
29+
*args,
30+
**kwargs,
31+
):
32+
super().__init__(*args, **kwargs)
33+
self.product = product
34+
35+
self.add_api_route(
36+
path="/opportunities",
37+
endpoint=self.search_opportunities,
38+
methods=["POST"],
39+
summary="Search Opportunities for the product",
40+
)
41+
42+
self.add_api_route(
43+
path="/constraints",
44+
endpoint=self.get_product_constraints,
45+
methods=["GET"],
46+
summary="Get constraints for the product",
47+
)
48+
49+
self.add_api_route(
50+
path="/order",
51+
endpoint=self.create_order,
52+
methods=["POST"],
53+
summary="Create an order for the product",
54+
)
55+
56+
async def search_opportunities(
57+
self, search: OpportunityRequest, request: Request
58+
) -> OpportunityCollection:
59+
"""
60+
Explore the opportunities available for a particular set of constraints
61+
"""
62+
try:
63+
opportunities = await self.product.backend.search_opportunities(
64+
search, request
65+
)
66+
except ConstraintsException as exc:
67+
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail)
68+
return JSONResponse(
69+
jsonable_encoder(OpportunityCollection(features=opportunities)),
70+
media_type=TYPE_GEOJSON,
71+
)
72+
73+
async def get_product_constraints(self: Self, request: Request) -> JsonSchemaModel:
74+
"""
75+
Return supported constraints of a specific product
76+
"""
77+
return {
78+
"product_id": self.product.product_id,
79+
"constraints": self.product.constraints,
80+
}
81+
82+
async def create_order(
83+
self, payload: OpportunityRequest, request: Request
84+
) -> JSONResponse:
85+
"""
86+
Create a new order.
87+
"""
88+
try:
89+
order = await self.product.backend.create_order(payload, request)
90+
except ConstraintsException as exc:
91+
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail)
92+
93+
location = str(
94+
request.url_for(f"{self.NAME_PREFIX}:get-order", order_id=order.id)
95+
)
96+
order.links.append(Link(href=location, rel="self", type=TYPE_GEOJSON))
97+
return JSONResponse(
98+
jsonable_encoder(order, exclude_unset=True),
99+
status.HTTP_201_CREATED,
100+
{"Location": location},
101+
TYPE_GEOJSON,
102+
)

0 commit comments

Comments
 (0)