From 494f9521d7c29b01159960f7bc16eddc55357d93 Mon Sep 17 00:00:00 2001 From: austin Date: Wed, 9 Oct 2024 10:41:12 -0700 Subject: [PATCH 01/27] initial implementation of router composition for products --- conftest.py | 4 +- stapi_fastapi/__dev__.py | 17 ++++++-- stapi_fastapi/exceptions.py | 4 ++ stapi_fastapi/{api.py => main_router.py} | 18 ++++---- stapi_fastapi/models/opportunity.py | 11 +++-- stapi_fastapi/products_router.py | 52 ++++++++++++++++++++++++ stapi_fastapi/umbra_spotlight_router.py | 4 ++ 7 files changed, 92 insertions(+), 18 deletions(-) rename stapi_fastapi/{api.py => main_router.py} (96%) create mode 100644 stapi_fastapi/products_router.py create mode 100644 stapi_fastapi/umbra_spotlight_router.py diff --git a/conftest.py b/conftest.py index 9a0cdd1..c198a75 100644 --- a/conftest.py +++ b/conftest.py @@ -5,7 +5,7 @@ from fastapi.testclient import TestClient from pytest import Parser, fixture -from stapi_fastapi.api import StapiRouter +from stapi_fastapi.main_router import MainRouter from stapi_fastapi_test_backend import TestBackend T = TypeVar("T") @@ -38,7 +38,7 @@ def stapi_client(stapi_backend, base_url: str) -> YieldFixture[TestClient]: app = FastAPI() app.include_router( - StapiRouter(backend=stapi_backend).router, + MainRouter(backend=stapi_backend).router, prefix="", ) diff --git a/stapi_fastapi/__dev__.py b/stapi_fastapi/__dev__.py index 1e65dc9..92ea9a0 100755 --- a/stapi_fastapi/__dev__.py +++ b/stapi_fastapi/__dev__.py @@ -13,8 +13,8 @@ print("install uvicorn and pydantic-settings to use the dev server", file=stderr) exit(1) -from stapi_fastapi.api import StapiRouter - +from stapi_fastapi.main_router import MainRouter +from stapi_fastapi.routers.umbra_spotlight_router import umbra_spotlight_router class DevSettings(BaseSettings): port: int = 8000 @@ -34,8 +34,19 @@ def validate_backend(cls, value: str): settings = DevSettings() backend = settings.backend_name() + +# Compose the router +main_router = MainRouter() +main_router.include_router(umbra_spotlight_router, prefix="/umbra_spotlight") + app = FastAPI(debug=True) -app.include_router(StapiRouter(backend).router) +app.include_router(main_router) + +# Define a root endpoint +# TODO: do I need this? +@app.get("/") +async def read_root(): + return {"message": "Welcome to STAPI AKA STAC to the Future AKA STAC-ish Order API"} def cli(): diff --git a/stapi_fastapi/exceptions.py b/stapi_fastapi/exceptions.py index cbbf7e9..9d7fe39 100644 --- a/stapi_fastapi/exceptions.py +++ b/stapi_fastapi/exceptions.py @@ -1,5 +1,9 @@ from typing import Any, Mapping +from fastapi import HTTPException +class StapiException(HTTPException): + def __init__(self, status_code: int, detail: str) -> None: + super().__init__(status_code, detail) class ConstraintsException(Exception): detail: Mapping[str, Any] | None diff --git a/stapi_fastapi/api.py b/stapi_fastapi/main_router.py similarity index 96% rename from stapi_fastapi/api.py rename to stapi_fastapi/main_router.py index c7e19d6..1f3c41c 100644 --- a/stapi_fastapi/api.py +++ b/stapi_fastapi/main_router.py @@ -4,7 +4,7 @@ from stapi_fastapi.backend import StapiBackend from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON -from stapi_fastapi.exceptions import ConstraintsException, NotFoundException +from stapi_fastapi.exceptions import StapiException, ConstraintsException, NotFoundException from stapi_fastapi.models.opportunity import ( OpportunityCollection, OpportunityRequest, @@ -15,14 +15,12 @@ from stapi_fastapi.models.shared import HTTPException as HTTPExceptionModel from stapi_fastapi.models.shared import Link - -class StapiException(HTTPException): - def __init__(self, status_code: int, detail: str) -> None: - super().__init__(status_code, detail) - - -class StapiRouter: - NAME_PREFIX = "stapi" +""" +/products/{component router} # router for each product added to main router +/orders # list all orders +""" +class MainRouter: + NAME_PREFIX = "main" backend: StapiBackend openapi_endpoint_name: str docs_endpoint_name: str @@ -56,6 +54,7 @@ def __init__( name=f"{self.NAME_PREFIX}:list-products", tags=["Product"], ) + self.router.add_api_route( "/products/{product_id}", self.product, @@ -81,6 +80,7 @@ def __init__( tags=["Orders"], response_model=Order, ) + self.router.add_api_route( "/orders/{order_id}", self.get_order, diff --git a/stapi_fastapi/models/opportunity.py b/stapi_fastapi/models/opportunity.py index 302e9db..c09a1fa 100644 --- a/stapi_fastapi/models/opportunity.py +++ b/stapi_fastapi/models/opportunity.py @@ -8,18 +8,21 @@ from stapi_fastapi.types.filter import CQL2Filter +# GENERIC Python # Copied and modified from https://github.com/stac-utils/stac-pydantic/blob/main/stac_pydantic/item.py#L11 class OpportunityProperties(BaseModel): datetime: DatetimeInterval - product_id: str model_config = ConfigDict(extra="allow") - -class OpportunityRequest(OpportunityProperties): +# NOT GENERIC +class OpportunityRequest(BaseModel): + datetime: DatetimeInterval geometry: Geometry + # TODO: validate the CQL2 filter? filter: Optional[CQL2Filter] = None + # PHILOSOPH: strict? - +# GENERIC: Each product needs an opportunity model (constraints/parameters) class Opportunity(Feature[Geometry, OpportunityProperties]): type: Literal["Feature"] = "Feature" diff --git a/stapi_fastapi/products_router.py b/stapi_fastapi/products_router.py new file mode 100644 index 0000000..f60dd96 --- /dev/null +++ b/stapi_fastapi/products_router.py @@ -0,0 +1,52 @@ +# Generic product router factory +from fastapi import APIRouter, HTTPException, status, Request +from typing import Dict, Any +from stapi_fastapi.models.opportunity import OpportunityRequest +from stapi_fastapi.backend import StapiBackend +from stapi_fastapi.exceptions import ConstraintsException + +""" +/products[MainRouter]/opportunities +/products[MainRouter]/parameters +/products[MainRouter]/order +""" + +def create_products_router(product_id: str, backend: StapiBackend) -> APIRouter: + # TODO: map product names to product IDs + """ + Creates a new APIRouter for a specific product type with standardized routes. + + Args: + product_id (str): The name of the product type (e.g., 'electronics', 'furniture'). + backend (StapiBackend): Backend instance implementing the StapiBackend protocol. + + Returns: + APIRouter: A FastAPI APIRouter configured for the product type. + """ + # Create a new router for the given product type + router = APIRouter(prefix=f"/{product_id}", tags=[product_id.capitalize()]) + + @router.get("/opportunities", summary="Get Opportunities for the product") + async def get_opportunities(request: Request, search: OpportunityRequest): + try: + opportunities = await backend.search_opportunities(search, request) + return opportunities + except ConstraintsException as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail) + + @router.get("/parameters", summary="Get parameters for the product") + async def get_product_parameters(request: Request): + product = backend.product(product_id, request) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + return {"product_id": product.id, "parameters": product.parameters} + + @router.post("/order", summary="Create an order for the product") + async def create_order(request: Request, payload: OpportunityRequest): + try: + order = await backend.create_order(payload, request) + return order + except ConstraintsException as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail) + + return router \ No newline at end of file diff --git a/stapi_fastapi/umbra_spotlight_router.py b/stapi_fastapi/umbra_spotlight_router.py new file mode 100644 index 0000000..9de0b4a --- /dev/null +++ b/stapi_fastapi/umbra_spotlight_router.py @@ -0,0 +1,4 @@ +from routers.products_router import create_products_router + +# Create a router for electronics using the base factory function +umbra_spotlight_router = create_products_router("umbra_spotlight") From c0d7a2313c83436480479b90e07ad0ec94ef5337 Mon Sep 17 00:00:00 2001 From: austin Date: Wed, 9 Oct 2024 11:29:22 -0700 Subject: [PATCH 02/27] make OpportunityProperties and Opportunity models generics --- stapi_fastapi/backend.py | 1 - stapi_fastapi/models/opportunity.py | 16 ++++++++++------ stapi_fastapi/products_router.py | 2 -- stapi_fastapi/umbra_spotlight_router.py | 2 +- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/stapi_fastapi/backend.py b/stapi_fastapi/backend.py index 4023718..9e37462 100644 --- a/stapi_fastapi/backend.py +++ b/stapi_fastapi/backend.py @@ -1,5 +1,4 @@ from typing import Protocol - from fastapi import Request from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest diff --git a/stapi_fastapi/models/opportunity.py b/stapi_fastapi/models/opportunity.py index c09a1fa..fe2528c 100644 --- a/stapi_fastapi/models/opportunity.py +++ b/stapi_fastapi/models/opportunity.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional +from typing import Literal, Optional, TypeVar, Generic from geojson_pydantic import Feature, FeatureCollection from geojson_pydantic.geometries import Geometry @@ -7,14 +7,14 @@ from stapi_fastapi.types.datetime_interval import DatetimeInterval from stapi_fastapi.types.filter import CQL2Filter +# Generic type definition for Opportunity Properties +T = TypeVar("T") -# GENERIC Python # Copied and modified from https://github.com/stac-utils/stac-pydantic/blob/main/stac_pydantic/item.py#L11 -class OpportunityProperties(BaseModel): +class OpportunityProperties(BaseModel, Generic[T]): datetime: DatetimeInterval model_config = ConfigDict(extra="allow") -# NOT GENERIC class OpportunityRequest(BaseModel): datetime: DatetimeInterval geometry: Geometry @@ -22,8 +22,12 @@ class OpportunityRequest(BaseModel): filter: Optional[CQL2Filter] = None # PHILOSOPH: strict? -# GENERIC: Each product needs an opportunity model (constraints/parameters) -class Opportunity(Feature[Geometry, OpportunityProperties]): +# Generic type definition for Opportunity +P = TypeVar("P", bound=OpportunityProperties) +K = TypeVar("K", bound=Geometry) + +# Each product implements its own opportunity model +class Opportunity(Feature[K, P], Generic[K, P]): type: Literal["Feature"] = "Feature" diff --git a/stapi_fastapi/products_router.py b/stapi_fastapi/products_router.py index f60dd96..7538b47 100644 --- a/stapi_fastapi/products_router.py +++ b/stapi_fastapi/products_router.py @@ -1,6 +1,5 @@ # Generic product router factory from fastapi import APIRouter, HTTPException, status, Request -from typing import Dict, Any from stapi_fastapi.models.opportunity import OpportunityRequest from stapi_fastapi.backend import StapiBackend from stapi_fastapi.exceptions import ConstraintsException @@ -12,7 +11,6 @@ """ def create_products_router(product_id: str, backend: StapiBackend) -> APIRouter: - # TODO: map product names to product IDs """ Creates a new APIRouter for a specific product type with standardized routes. diff --git a/stapi_fastapi/umbra_spotlight_router.py b/stapi_fastapi/umbra_spotlight_router.py index 9de0b4a..db3e0ed 100644 --- a/stapi_fastapi/umbra_spotlight_router.py +++ b/stapi_fastapi/umbra_spotlight_router.py @@ -1,4 +1,4 @@ from routers.products_router import create_products_router # Create a router for electronics using the base factory function -umbra_spotlight_router = create_products_router("umbra_spotlight") +umbra_spotlight_router = create_products_router("umbra-spotlight-1") From 3139ec478362b60b0fc64ef25805f60948cdb5a6 Mon Sep 17 00:00:00 2001 From: austin Date: Wed, 9 Oct 2024 14:24:24 -0700 Subject: [PATCH 03/27] overhaul the main router as APIRouter, product router not a factory, add product router classmethod to main router --- conftest.py | 67 ++++++++++++++- stapi_fastapi/main_router.py | 129 ++++++---------------------- stapi_fastapi/models/opportunity.py | 5 +- stapi_fastapi/models/product.py | 31 ++++++- stapi_fastapi/products_router.py | 46 +++++----- tests/opportunity_test.py | 65 ++++++++------ 6 files changed, 179 insertions(+), 164 deletions(-) diff --git a/conftest.py b/conftest.py index c198a75..a8d2739 100644 --- a/conftest.py +++ b/conftest.py @@ -1,12 +1,20 @@ -from typing import Callable, Generator, TypeVar +from typing import Callable, Generator, TypeVar, List from urllib.parse import urljoin +from uuid import uuid4 +import pytest +from datetime import datetime, timezone, timedelta +from geojson_pydantic import Point from fastapi import FastAPI from fastapi.testclient import TestClient from pytest import Parser, fixture +from stapi_fastapi.models.product import Product, Provider, ProviderRole from stapi_fastapi.main_router import MainRouter from stapi_fastapi_test_backend import TestBackend +from stapi_fastapi.models.shared import Link +from stapi_fastapi.models.opportunity import OpportunityProperties, Opportunity +from stapi_fastapi.types.datetime_interval import DatetimeInterval T = TypeVar("T") @@ -54,3 +62,60 @@ def url_for(value: str) -> str: return urljoin(with_trailing_slash(base_url), f"./{value.lstrip('/')}") yield url_for + +@pytest.fixture +def mock_provider_umbra() -> Provider: + return Provider( + name="Umbra Provider", + description="A provider for Umbra data", + roles=[ProviderRole.producer], # Example role + url="https://umbra-provider.example.com" # Must be a valid URL + ) + +# Define a mock OpportunityProperties class for Umbra +class UmbraSpotlightProperties(OpportunityProperties): + datetime: DatetimeInterval + +@pytest.fixture +def mock_product_umbra_spotlight(mock_provider_umbra: Provider) -> Product: + """Fixture for a mock Umbra Spotlight product.""" + + return Product( + id=str(uuid4()), + title="Umbra Spotlight Product", + description="Test product for umbra spotlight", + license="CC-BY-4.0", + keywords=["test", "umbra", "satellite"], + providers=[mock_provider_umbra], + links=[ + Link(href="http://example.com", rel="self"), + Link(href="http://example.com/catalog", rel="parent"), + ], + parameters=UmbraSpotlightProperties + ) + +@pytest.fixture +def mock_products(mock_product_umbra_spotlight: Product) -> List[Product]: + """Fixture to return a list of mock products.""" + return [mock_product_umbra_spotlight] + +@pytest.fixture +def mock_umbra_spotlight_opportunities() -> List[Opportunity]: + """Fixture to create mock data for Opportunities for `umbra-spotlight-1`.""" + now = datetime.now(timezone.utc) # Use timezone-aware datetime + start = now + end = start + timedelta(days=5) + datetime_interval = f"{start.isoformat()}/{end.isoformat()}" + + # Create a list of mock opportunities for the given product + return [ + Opportunity( + id=str(uuid4()), + type="Feature", + geometry=Point(type="Point", coordinates=[0, 0]), # Simple point geometry + properties=UmbraSpotlightProperties( + datetime=datetime_interval, + off_nadir=20, + ), + ), + ] \ No newline at end of file diff --git a/stapi_fastapi/main_router.py b/stapi_fastapi/main_router.py index 1f3c41c..d5804ff 100644 --- a/stapi_fastapi/main_router.py +++ b/stapi_fastapi/main_router.py @@ -1,3 +1,4 @@ +from typing import Self, Optional from fastapi import APIRouter, HTTPException, Request, status from fastapi.encoders import jsonable_encoder from fastapi.responses import JSONResponse @@ -14,78 +15,52 @@ from stapi_fastapi.models.root import RootResponse from stapi_fastapi.models.shared import HTTPException as HTTPExceptionModel from stapi_fastapi.models.shared import Link +from stapi_fastapi.products_router import ProductRouter """ /products/{component router} # router for each product added to main router /orders # list all orders """ -class MainRouter: - NAME_PREFIX = "main" - backend: StapiBackend - openapi_endpoint_name: str - docs_endpoint_name: str - router: APIRouter +class MainRouter(APIRouter): def __init__( - self, + self: Self, backend: StapiBackend, - openapi_endpoint_name="openapi", - docs_endpoint_name="swagger_ui_html", + name: str = "main", + openapi_endpoint_name: str = "openapi", + docs_endpoint_name: str = "swagger_ui_html", *args, **kwargs, ): + super().__init__(*args, **kwargs) self.backend = backend + self.name = name self.openapi_endpoint_name = openapi_endpoint_name self.docs_endpoint_name = docs_endpoint_name - self.router = APIRouter(*args, **kwargs) - self.router.add_api_route( + self.product_routers: dict[str, ProductRouter] = {} + + self.add_api_route( "/", self.root, methods=["GET"], - name=f"{self.NAME_PREFIX}:root", + name=f"{self.name}:root", tags=["Root"], ) - self.router.add_api_route( + self.add_api_route( "/products", self.products, methods=["GET"], - name=f"{self.NAME_PREFIX}:list-products", - tags=["Product"], - ) - - self.router.add_api_route( - "/products/{product_id}", - self.product, - methods=["GET"], - name=f"{self.NAME_PREFIX}:get-product", + name=f"{self.name}:list-products", tags=["Product"], - responses={status.HTTP_404_NOT_FOUND: {"model": HTTPExceptionModel}}, ) - self.router.add_api_route( - "/opportunities", - self.search_opportunities, - methods=["POST"], - name=f"{self.NAME_PREFIX}:search-opportunities", - tags=["Opportunities"], - ) - - self.router.add_api_route( - "/orders", - self.create_order, - methods=["POST"], - name=f"{self.NAME_PREFIX}:create-order", - tags=["Orders"], - response_model=Order, - ) - - self.router.add_api_route( + self.add_api_route( "/orders/{order_id}", self.get_order, methods=["GET"], - name=f"{self.NAME_PREFIX}:get-order", + name=f"{self.name}:get-order", tags=["Orders"], ) @@ -93,12 +68,12 @@ def root(self, request: Request) -> RootResponse: return RootResponse( links=[ Link( - href=str(request.url_for(f"{self.NAME_PREFIX}:root")), + href=str(request.url_for(f"{self.name}:root")), rel="self", type=TYPE_JSON, ), Link( - href=str(request.url_for(f"{self.NAME_PREFIX}:list-products")), + href=str(request.url_for(f"{self.name}:list-products")), rel="products", type=TYPE_JSON, ), @@ -122,7 +97,7 @@ def products(self, request: Request) -> ProductsCollection: Link( href=str( request.url_for( - f"{self.NAME_PREFIX}:get-product", product_id=product.id + f"{self.name}:get-product", product_id=product.id ) ), rel="self", @@ -133,70 +108,13 @@ def products(self, request: Request) -> ProductsCollection: products=products, links=[ Link( - href=str(request.url_for(f"{self.NAME_PREFIX}:list-products")), + href=str(request.url_for(f"{self.name}:list-products")), rel="self", type=TYPE_JSON, ) ], ) - def product(self, product_id: str, request: Request) -> Product: - try: - product = self.backend.product(product_id, request) - except NotFoundException as exc: - raise StapiException( - status.HTTP_404_NOT_FOUND, "product not found" - ) from exc - product.links.append( - Link( - href=str( - request.url_for( - f"{self.NAME_PREFIX}:get-product", product_id=product.id - ) - ), - rel="self", - type=TYPE_JSON, - ) - ) - return product - - async def search_opportunities( - self, search: OpportunityRequest, request: Request - ) -> OpportunityCollection: - """ - Explore the opportunities available for a particular set of constraints - """ - try: - opportunities = await self.backend.search_opportunities(search, request) - except ConstraintsException as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail) - return JSONResponse( - jsonable_encoder(OpportunityCollection(features=opportunities)), - media_type=TYPE_GEOJSON, - ) - - async def create_order( - self, search: OpportunityRequest, request: Request - ) -> JSONResponse: - """ - Create a new order. - """ - try: - order = await self.backend.create_order(search, request) - except ConstraintsException as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail) - - location = str( - request.url_for(f"{self.NAME_PREFIX}:get-order", order_id=order.id) - ) - order.links.append(Link(href=location, rel="self", type=TYPE_GEOJSON)) - return JSONResponse( - jsonable_encoder(order, exclude_unset=True), - status.HTTP_201_CREATED, - {"Location": location}, - TYPE_GEOJSON, - ) - async def get_order(self, order_id: str, request: Request) -> Order: """ Get details for order with `order_id`. @@ -213,3 +131,8 @@ async def get_order(self, order_id: str, request: Request) -> Order: status.HTTP_200_OK, media_type=TYPE_GEOJSON, ) + + def add_product_router(self, product_router: ProductRouter): + # Give the include a prefix from the product router + self.include_router(product_router, prefix=product_router.product.id) + self.product_routers[product_router.product.id] = product_router diff --git a/stapi_fastapi/models/opportunity.py b/stapi_fastapi/models/opportunity.py index fe2528c..e9b6279 100644 --- a/stapi_fastapi/models/opportunity.py +++ b/stapi_fastapi/models/opportunity.py @@ -20,7 +20,7 @@ class OpportunityRequest(BaseModel): geometry: Geometry # TODO: validate the CQL2 filter? filter: Optional[CQL2Filter] = None - # PHILOSOPH: strict? + model_config = ConfigDict(strict=True) # Generic type definition for Opportunity P = TypeVar("P", bound=OpportunityProperties) @@ -30,6 +30,5 @@ class OpportunityRequest(BaseModel): class Opportunity(Feature[K, P], Generic[K, P]): type: Literal["Feature"] = "Feature" - -class OpportunityCollection(FeatureCollection[Opportunity]): +class OpportunityCollection(FeatureCollection[Opportunity[K, P]], Generic[K, P]): type: Literal["FeatureCollection"] = "FeatureCollection" diff --git a/stapi_fastapi/models/product.py b/stapi_fastapi/models/product.py index b8df057..8e1b064 100644 --- a/stapi_fastapi/models/product.py +++ b/stapi_fastapi/models/product.py @@ -1,10 +1,13 @@ from enum import Enum from typing import Literal, Optional +from abc import ABC, abstractmethod +from fastapi import Request from pydantic import AnyHttpUrl, BaseModel, Field from stapi_fastapi.models.shared import Link -from stapi_fastapi.types.json_schema_model import JsonSchemaModel +from stapi_fastapi.models.opportunity import Opportunity, OpportunityProperties, OpportunityRequest +from stapi_fastapi.models.order import Order class ProviderRole(str, Enum): @@ -21,7 +24,7 @@ class Provider(BaseModel): url: AnyHttpUrl -class Product(BaseModel): +class Product(BaseModel, ABC): type: Literal["Product"] = "Product" conformsTo: list[str] = Field(default_factory=list) id: str @@ -31,8 +34,28 @@ class Product(BaseModel): license: str providers: list[Provider] = Field(default_factory=list) links: list[Link] - parameters: JsonSchemaModel - + parameters: OpportunityProperties + + @abstractmethod + def search_opportunities(self, search: OpportunityRequest, request: Request + ) -> list[Opportunity]: + """ + Search for ordering opportunities for the given search parameters. + + Backends must validate search constraints and raise + `stapi_fastapi.backend.exceptions.ConstraintsException` if not valid. + """ + ... + + @abstractmethod + def create_order(self, search: OpportunityRequest, request: Request) -> Order: + """ + Create a new order. + + Backends must validate order payload and raise + `stapi_fastapi.backend.exceptions.ConstraintsException` if not valid. + """ + ... class ProductsCollection(BaseModel): type: Literal["ProductCollection"] = "ProductCollection" diff --git a/stapi_fastapi/products_router.py b/stapi_fastapi/products_router.py index 7538b47..61e5184 100644 --- a/stapi_fastapi/products_router.py +++ b/stapi_fastapi/products_router.py @@ -1,6 +1,8 @@ # Generic product router factory +from typing import Self from fastapi import APIRouter, HTTPException, status, Request from stapi_fastapi.models.opportunity import OpportunityRequest +from stapi_fastapi.models.product import Product from stapi_fastapi.backend import StapiBackend from stapi_fastapi.exceptions import ConstraintsException @@ -10,41 +12,35 @@ /products[MainRouter]/order """ -def create_products_router(product_id: str, backend: StapiBackend) -> APIRouter: - """ - Creates a new APIRouter for a specific product type with standardized routes. +class ProductRouter(APIRouter): - Args: - product_id (str): The name of the product type (e.g., 'electronics', 'furniture'). - backend (StapiBackend): Backend instance implementing the StapiBackend protocol. + def __init__( + self: Self, + product: Product, + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.product = product - Returns: - APIRouter: A FastAPI APIRouter configured for the product type. - """ - # Create a new router for the given product type - router = APIRouter(prefix=f"/{product_id}", tags=[product_id.capitalize()]) + # TODO: use add_api_route + self.post("/opportunities", summary="Search Opportunities for the product")(self.search_opportunities) + self.get("/parameters", summary="Get parameters for the product")(self.get_product_parameters) + self.post("/order", summary="Create an order for the product")(self.create_order) - @router.get("/opportunities", summary="Get Opportunities for the product") - async def get_opportunities(request: Request, search: OpportunityRequest): + async def search_opportunities(self: Self, request: Request, search: OpportunityRequest): try: - opportunities = await backend.search_opportunities(search, request) + opportunities = await self.product.search_opportunities(search, request) return opportunities except ConstraintsException as exc: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail) - @router.get("/parameters", summary="Get parameters for the product") - async def get_product_parameters(request: Request): - product = backend.product(product_id, request) - if not product: - raise HTTPException(status_code=404, detail="Product not found") - return {"product_id": product.id, "parameters": product.parameters} + async def get_product_parameters(self: Self, request: Request): + return {"product_id": self.product.id, "parameters": self.product.parameters} - @router.post("/order", summary="Create an order for the product") - async def create_order(request: Request, payload: OpportunityRequest): + async def create_order(self: Self, request: Request, payload: OpportunityRequest): try: - order = await backend.create_order(payload, request) + order = await self.product.create_order(payload, request) return order except ConstraintsException as exc: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail) - - return router \ No newline at end of file diff --git a/tests/opportunity_test.py b/tests/opportunity_test.py index c2126d2..cbb51eb 100644 --- a/tests/opportunity_test.py +++ b/tests/opportunity_test.py @@ -1,46 +1,55 @@ +import pytest +from typing import List from datetime import UTC, datetime, timedelta -from fastapi import status from fastapi.testclient import TestClient -from stapi_fastapi.models.opportunity import Opportunity, OpportunityCollection +from stapi_fastapi.models.opportunity import Opportunity, OpportunityProperties from stapi_fastapi.models.product import Product from stapi_fastapi_test_backend.backend import TestBackend - +@pytest.mark.parametrize("product_id", ["umbra-spotlight-1"]) def test_search_opportunities_response( - products: list[Product], - opportunities: list[Opportunity], + product_id: str, + mock_products: list[Product], + mock_umbra_spotlight_opportunities: List[Opportunity], stapi_backend: TestBackend, stapi_client: TestClient, ): - stapi_backend._products = products - stapi_backend._opportunities = opportunities + stapi_backend._products = mock_products + stapi_backend._opportunities = mock_umbra_spotlight_opportunities now = datetime.now(UTC) start = now end = start + timedelta(days=5) - res = stapi_client.post( - "/opportunities", - json={ - "geometry": { - "type": "Point", - "coordinates": [0, 0], - }, - "product_id": products[0].id, - "datetime": f"{start.isoformat()}/{end.isoformat()}", - "filter": { - "op": "and", - "args": [ - {"op": ">", "args": [{"property": "off_nadir"}, 0]}, - {"op": "<", "args": [{"property": "off_nadir"}, 45]}, - ], - }, + # Create mock products and opportunities for the test + mock_products[0].id = product_id + + # Prepare the request payload + request_payload = { + "geometry": { + "type": "Point", + "coordinates": [0, 0], + }, + "filter": { + "op": "and", + "args": [ + {"op": ">", "args": [{"property": "off_nadir"}, 0]}, + {"op": "<", "args": [{"property": "off_nadir"}, 45]}, + ], }, - ) - assert res.status_code == status.HTTP_200_OK - assert res.headers["Content-Type"] == "application/geo+json" - response = OpportunityCollection(**res.json()) + } + + # Construct the endpoint URL using the `product_name` parameter + url = f"/products/{product_id}/opportunities" + + # Use POST method to send the payload + response = stapi_client.post(url, json=request_payload) - assert len(response.features) > 0 + # Validate response status and structure + assert response.status_code == 200, f"Failed for product: {product_id}" + assert isinstance(response.json(), list), "Response should be a list of opportunities" + for opportunity in response.json(): + assert "id" in opportunity, "Opportunity item should have an 'id' field" + assert "properties" in opportunity, "Opportunity item should have a 'properties' field" \ No newline at end of file From 1ac2cc8de51badff5cab8f900c8366131696709b Mon Sep 17 00:00:00 2001 From: austin Date: Wed, 9 Oct 2024 14:45:51 -0700 Subject: [PATCH 04/27] resolve merge conflicts --- poetry.lock | 6 +- stapi_fastapi/__dev__.py | 55 ------------------ tests/conftest.py | 122 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 123 insertions(+), 60 deletions(-) delete mode 100755 stapi_fastapi/__dev__.py diff --git a/poetry.lock b/poetry.lock index 00c4eb5..673f934 100644 --- a/poetry.lock +++ b/poetry.lock @@ -207,13 +207,13 @@ langdetect = ["langdetect"] [[package]] name = "distlib" -version = "0.3.8" +version = "0.3.9" description = "Distribution utilities" optional = false python-versions = "*" files = [ - {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, - {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, + {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, + {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, ] [[package]] diff --git a/stapi_fastapi/__dev__.py b/stapi_fastapi/__dev__.py deleted file mode 100755 index 92ea9a0..0000000 --- a/stapi_fastapi/__dev__.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python3 - -from importlib import import_module -from sys import stderr - -from fastapi import FastAPI -from pydantic import field_validator - -try: - from pydantic_settings import BaseSettings - from uvicorn.main import run -except ImportError: - print("install uvicorn and pydantic-settings to use the dev server", file=stderr) - exit(1) - -from stapi_fastapi.main_router import MainRouter -from stapi_fastapi.routers.umbra_spotlight_router import umbra_spotlight_router - -class DevSettings(BaseSettings): - port: int = 8000 - host: str = "127.0.0.1" - backend_name: str = "stapi_fastapi_landsat:LandsatBackend" - - @field_validator("backend_name", mode="after") - def validate_backend(cls, value: str): - try: - mod, attr = value.split(":", maxsplit=1) - mod = import_module(mod) - attr = getattr(mod, attr) - except ImportError as exc: - raise ValueError("not a valid backend") from exc - return attr - - -settings = DevSettings() -backend = settings.backend_name() - -# Compose the router -main_router = MainRouter() -main_router.include_router(umbra_spotlight_router, prefix="/umbra_spotlight") - -app = FastAPI(debug=True) -app.include_router(main_router) - -# Define a root endpoint -# TODO: do I need this? -@app.get("/") -async def read_root(): - return {"message": "Welcome to STAPI AKA STAC to the Future AKA STAC-ish Order API"} - - -def cli(): - run( - "stapi_fastapi.__dev__:app", reload=True, host=settings.host, port=settings.port - ) diff --git a/tests/conftest.py b/tests/conftest.py index 944e8cd..81f335a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,17 @@ +from typing import Callable, Generator, TypeVar, List +from urllib.parse import urljoin +from uuid import uuid4 +import pytest +from datetime import datetime, timezone, timedelta +from geojson_pydantic import Point +from pytest import Parser, fixture + +from stapi_fastapi.models.product import Product, Provider, ProviderRole +from stapi_fastapi.main_router import MainRouter +from stapi_fastapi.models.shared import Link +from stapi_fastapi.models.opportunity import OpportunityProperties, Opportunity +from stapi_fastapi.types.datetime_interval import DatetimeInterval + from collections.abc import Iterator from datetime import UTC, datetime from typing import Callable @@ -8,9 +22,9 @@ from fastapi.testclient import TestClient from geojson_pydantic import Point from pydantic import BaseModel -from stapi_fastapi.api import StapiRouter from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest from stapi_fastapi.models.product import Product, Provider, ProviderRole +from stapi_fastapi.products_router import ProductRouter from .backend import TestBackend @@ -30,7 +44,7 @@ def stapi_client(stapi_backend, base_url: str) -> Iterator[TestClient]: app = FastAPI() app.include_router( - StapiRouter(backend=stapi_backend).router, + ProductRouter(backend=stapi_backend).router, prefix="", ) @@ -100,3 +114,107 @@ def allowed_payloads(products: list[Product]) -> Iterator[list[OpportunityReques filter={}, ), ] + +T = TypeVar("T") + +YieldFixture = Generator[T, None, None] + + +def pytest_addoption(parser: Parser): + parser.addoption( + "--stapi-backend", + action="store", + default="stapi_fastapi_test_backend:TestBackend", + ) + parser.addoption("--stapi-prefix", action="store", default="/stapi") + parser.addoption("--stapi-product-id", action="store", default="mock:standard") + + +@fixture(scope="session") +def base_url() -> YieldFixture[str]: + yield "http://stapiserver" + + +@fixture +def stapi_backend(): + yield TestBackend() + + +@fixture +def stapi_client(stapi_backend, base_url: str) -> YieldFixture[TestClient]: + app = FastAPI() + + app.include_router( + MainRouter(backend=stapi_backend).router, + prefix="", + ) + + yield TestClient(app, base_url=f"{base_url}") + + +@fixture(scope="session") +def url_for(base_url: str) -> YieldFixture[Callable[[str], str]]: + def with_trailing_slash(value: str) -> str: + return value if value.endswith("/") else f"{value}/" + + def url_for(value: str) -> str: + return urljoin(with_trailing_slash(base_url), f"./{value.lstrip('/')}") + + yield url_for + +@pytest.fixture +def mock_provider_umbra() -> Provider: + return Provider( + name="Umbra Provider", + description="A provider for Umbra data", + roles=[ProviderRole.producer], # Example role + url="https://umbra-provider.example.com" # Must be a valid URL + ) + +# Define a mock OpportunityProperties class for Umbra +class UmbraSpotlightProperties(OpportunityProperties): + datetime: DatetimeInterval + +@pytest.fixture +def mock_product_umbra_spotlight(mock_provider_umbra: Provider) -> Product: + """Fixture for a mock Umbra Spotlight product.""" + + return Product( + id=str(uuid4()), + title="Umbra Spotlight Product", + description="Test product for umbra spotlight", + license="CC-BY-4.0", + keywords=["test", "umbra", "satellite"], + providers=[mock_provider_umbra], + links=[ + Link(href="http://example.com", rel="self"), + Link(href="http://example.com/catalog", rel="parent"), + ], + parameters=UmbraSpotlightProperties + ) + +@pytest.fixture +def mock_products(mock_product_umbra_spotlight: Product) -> List[Product]: + """Fixture to return a list of mock products.""" + return [mock_product_umbra_spotlight] + +@pytest.fixture +def mock_umbra_spotlight_opportunities() -> List[Opportunity]: + """Fixture to create mock data for Opportunities for `umbra-spotlight-1`.""" + now = datetime.now(timezone.utc) # Use timezone-aware datetime + start = now + end = start + timedelta(days=5) + datetime_interval = f"{start.isoformat()}/{end.isoformat()}" + + # Create a list of mock opportunities for the given product + return [ + Opportunity( + id=str(uuid4()), + type="Feature", + geometry=Point(type="Point", coordinates=[0, 0]), # Simple point geometry + properties=UmbraSpotlightProperties( + datetime=datetime_interval, + off_nadir=20, + ), + ), + ] \ No newline at end of file From 73b9c034e20646d6b3e014dd864b076f5912b8a1 Mon Sep 17 00:00:00 2001 From: austin Date: Wed, 9 Oct 2024 14:52:30 -0700 Subject: [PATCH 05/27] sort out the conftest a bit more --- tests/conftest.py | 67 ++++++++++++--------------------------- tests/opportunity_test.py | 2 +- 2 files changed, 21 insertions(+), 48 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 81f335a..c1f9cdf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,28 +2,19 @@ from urllib.parse import urljoin from uuid import uuid4 import pytest -from datetime import datetime, timezone, timedelta +from datetime import datetime, timezone, timedelta, UTC from geojson_pydantic import Point from pytest import Parser, fixture from stapi_fastapi.models.product import Product, Provider, ProviderRole -from stapi_fastapi.main_router import MainRouter from stapi_fastapi.models.shared import Link -from stapi_fastapi.models.opportunity import OpportunityProperties, Opportunity +from stapi_fastapi.models.opportunity import OpportunityProperties, Opportunity, OpportunityRequest from stapi_fastapi.types.datetime_interval import DatetimeInterval from collections.abc import Iterator -from datetime import UTC, datetime -from typing import Callable -from urllib.parse import urljoin - -import pytest from fastapi import FastAPI from fastapi.testclient import TestClient -from geojson_pydantic import Point from pydantic import BaseModel -from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest -from stapi_fastapi.models.product import Product, Provider, ProviderRole from stapi_fastapi.products_router import ProductRouter from .backend import TestBackend @@ -38,13 +29,30 @@ def base_url() -> Iterator[str]: def stapi_backend() -> Iterator[TestBackend]: yield TestBackend() +@pytest.fixture +def mock_product_umbra_spotlight(mock_provider_umbra: Provider) -> Product: + """Fixture for a mock Umbra Spotlight product.""" + + return Product( + id=str(uuid4()), + title="Umbra Spotlight Product", + description="Test product for umbra spotlight", + license="CC-BY-4.0", + keywords=["test", "umbra", "satellite"], + providers=[mock_provider_umbra], + links=[ + Link(href="http://example.com", rel="self"), + Link(href="http://example.com/catalog", rel="parent"), + ], + parameters=UmbraSpotlightProperties + ) @pytest.fixture def stapi_client(stapi_backend, base_url: str) -> Iterator[TestClient]: app = FastAPI() app.include_router( - ProductRouter(backend=stapi_backend).router, + ProductRouter(mock_product_umbra_spotlight), prefix="", ) @@ -135,23 +143,6 @@ def base_url() -> YieldFixture[str]: yield "http://stapiserver" -@fixture -def stapi_backend(): - yield TestBackend() - - -@fixture -def stapi_client(stapi_backend, base_url: str) -> YieldFixture[TestClient]: - app = FastAPI() - - app.include_router( - MainRouter(backend=stapi_backend).router, - prefix="", - ) - - yield TestClient(app, base_url=f"{base_url}") - - @fixture(scope="session") def url_for(base_url: str) -> YieldFixture[Callable[[str], str]]: def with_trailing_slash(value: str) -> str: @@ -175,24 +166,6 @@ def mock_provider_umbra() -> Provider: class UmbraSpotlightProperties(OpportunityProperties): datetime: DatetimeInterval -@pytest.fixture -def mock_product_umbra_spotlight(mock_provider_umbra: Provider) -> Product: - """Fixture for a mock Umbra Spotlight product.""" - - return Product( - id=str(uuid4()), - title="Umbra Spotlight Product", - description="Test product for umbra spotlight", - license="CC-BY-4.0", - keywords=["test", "umbra", "satellite"], - providers=[mock_provider_umbra], - links=[ - Link(href="http://example.com", rel="self"), - Link(href="http://example.com/catalog", rel="parent"), - ], - parameters=UmbraSpotlightProperties - ) - @pytest.fixture def mock_products(mock_product_umbra_spotlight: Product) -> List[Product]: """Fixture to return a list of mock products.""" diff --git a/tests/opportunity_test.py b/tests/opportunity_test.py index ee2edc8..4fa0974 100644 --- a/tests/opportunity_test.py +++ b/tests/opportunity_test.py @@ -3,7 +3,7 @@ from datetime import UTC, datetime, timedelta from fastapi.testclient import TestClient -from stapi_fastapi.models.opportunity import Opportunity, OpportunityCollection +from stapi_fastapi.models.opportunity import Opportunity from stapi_fastapi.models.product import Product from .backend import TestBackend From 6fa822be1b14ef055f86f129260e0458f283865e Mon Sep 17 00:00:00 2001 From: austin Date: Wed, 9 Oct 2024 16:07:24 -0700 Subject: [PATCH 06/27] the /products/{product_id}/opportunities endpoint is accessible --- src/stapi_fastapi/main_router.py | 4 +- src/stapi_fastapi/products_router.py | 24 +++++++-- tests/conftest.py | 77 ++++++++++++++++------------ tests/opportunity_test.py | 8 +-- tests/product_test.py | 1 + 5 files changed, 70 insertions(+), 44 deletions(-) diff --git a/src/stapi_fastapi/main_router.py b/src/stapi_fastapi/main_router.py index d5804ff..448bbae 100644 --- a/src/stapi_fastapi/main_router.py +++ b/src/stapi_fastapi/main_router.py @@ -51,7 +51,7 @@ def __init__( self.add_api_route( "/products", self.products, - methods=["GET"], + methods=["GET","POST"], name=f"{self.name}:list-products", tags=["Product"], ) @@ -134,5 +134,5 @@ async def get_order(self, order_id: str, request: Request) -> Order: def add_product_router(self, product_router: ProductRouter): # Give the include a prefix from the product router - self.include_router(product_router, prefix=product_router.product.id) + self.include_router(product_router, prefix=f"/products/{product_router.product.id}") self.product_routers[product_router.product.id] = product_router diff --git a/src/stapi_fastapi/products_router.py b/src/stapi_fastapi/products_router.py index 61e5184..0d5db46 100644 --- a/src/stapi_fastapi/products_router.py +++ b/src/stapi_fastapi/products_router.py @@ -23,10 +23,26 @@ def __init__( super().__init__(*args, **kwargs) self.product = product - # TODO: use add_api_route - self.post("/opportunities", summary="Search Opportunities for the product")(self.search_opportunities) - self.get("/parameters", summary="Get parameters for the product")(self.get_product_parameters) - self.post("/order", summary="Create an order for the product")(self.create_order) + self.add_api_route( + path="/opportunities", + endpoint=self.search_opportunities, + methods=["POST"], + summary="Search Opportunities for the product" + ) + + self.add_api_route( + path="/parameters", + endpoint=self.get_product_parameters, + methods=["GET"], + summary="Get parameters for the product" + ) + + self.add_api_route( + path="/order", + endpoint=self.create_order, + methods=["POST"], + summary="Create an order for the product" + ) async def search_opportunities(self: Self, request: Request, search: OpportunityRequest): try: diff --git a/tests/conftest.py b/tests/conftest.py index c1f9cdf..70ef177 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,29 +12,32 @@ from stapi_fastapi.types.datetime_interval import DatetimeInterval from collections.abc import Iterator -from fastapi import FastAPI from fastapi.testclient import TestClient -from pydantic import BaseModel from stapi_fastapi.products_router import ProductRouter +from stapi_fastapi.main_router import MainRouter from .backend import TestBackend +# Define concrete classes for Products to mock +class UmbraSpotlight(Product): + def search_opportunities(self, search, request): + return [] + def create_order(self, search, request): + return [] -@pytest.fixture(scope="session") -def base_url() -> Iterator[str]: - yield "http://stapiserver" - - -@pytest.fixture -def stapi_backend() -> Iterator[TestBackend]: - yield TestBackend() +class UmbraSpotlightProperties(OpportunityProperties): + datetime: DatetimeInterval @pytest.fixture def mock_product_umbra_spotlight(mock_provider_umbra: Provider) -> Product: """Fixture for a mock Umbra Spotlight product.""" + now = datetime.now(timezone.utc) # Use timezone-aware datetime + start = now + end = start + timedelta(days=5) + datetime_interval = f"{start.isoformat()}/{end.isoformat()}" - return Product( - id=str(uuid4()), + return UmbraSpotlight( + id="umbra-spotlight", title="Umbra Spotlight Product", description="Test product for umbra spotlight", license="CC-BY-4.0", @@ -44,16 +47,27 @@ def mock_product_umbra_spotlight(mock_provider_umbra: Provider) -> Product: Link(href="http://example.com", rel="self"), Link(href="http://example.com/catalog", rel="parent"), ], - parameters=UmbraSpotlightProperties + parameters=UmbraSpotlightProperties( + datetime=datetime_interval, + off_nadir=20, + ) ) +@pytest.fixture(scope="session") +def base_url() -> Iterator[str]: + yield "http://stapiserver" + + +@pytest.fixture +def stapi_backend() -> Iterator[TestBackend]: + yield TestBackend() + @pytest.fixture -def stapi_client(stapi_backend, base_url: str) -> Iterator[TestClient]: - app = FastAPI() +def stapi_client(stapi_backend, mock_product_umbra_spotlight, base_url: str) -> Iterator[TestClient]: + app = MainRouter(stapi_backend) - app.include_router( - ProductRouter(mock_product_umbra_spotlight), - prefix="", + app.add_product_router( + ProductRouter(mock_product_umbra_spotlight) ) yield TestClient(app, base_url=f"{base_url}") @@ -71,12 +85,14 @@ def url_for(value: str) -> str: @pytest.fixture -def products() -> Iterator[list[Product]]: - class Parameters(BaseModel): - pass +def products(mock_product_umbra_spotlight) -> Iterator[list[Product]]: + now = datetime.now(timezone.utc) # Use timezone-aware datetime + start = now + end = start + timedelta(days=5) + datetime_interval = f"{start.isoformat()}/{end.isoformat()}" yield [ - Product( + UmbraSpotlight( id="mock:standard", description="Mock backend's standard product", license="CC0-1.0", @@ -92,7 +108,10 @@ class Parameters(BaseModel): url="http://acme.example.com", ) ], - parameters=Parameters, + parameters=UmbraSpotlightProperties( + datetime=datetime_interval, + off_nadir=20, + ), links=[], ) ] @@ -127,7 +146,6 @@ def allowed_payloads(products: list[Product]) -> Iterator[list[OpportunityReques YieldFixture = Generator[T, None, None] - def pytest_addoption(parser: Parser): parser.addoption( "--stapi-backend", @@ -162,15 +180,6 @@ def mock_provider_umbra() -> Provider: url="https://umbra-provider.example.com" # Must be a valid URL ) -# Define a mock OpportunityProperties class for Umbra -class UmbraSpotlightProperties(OpportunityProperties): - datetime: DatetimeInterval - -@pytest.fixture -def mock_products(mock_product_umbra_spotlight: Product) -> List[Product]: - """Fixture to return a list of mock products.""" - return [mock_product_umbra_spotlight] - @pytest.fixture def mock_umbra_spotlight_opportunities() -> List[Opportunity]: """Fixture to create mock data for Opportunities for `umbra-spotlight-1`.""" @@ -190,4 +199,4 @@ def mock_umbra_spotlight_opportunities() -> List[Opportunity]: off_nadir=20, ), ), - ] \ No newline at end of file + ] diff --git a/tests/opportunity_test.py b/tests/opportunity_test.py index 4fa0974..def4820 100644 --- a/tests/opportunity_test.py +++ b/tests/opportunity_test.py @@ -8,15 +8,15 @@ from .backend import TestBackend -@pytest.mark.parametrize("product_id", ["umbra-spotlight-1"]) +@pytest.mark.parametrize("product_id", ["umbra-spotlight"]) def test_search_opportunities_response( product_id: str, - mock_products: list[Product], + products: list[Product], mock_umbra_spotlight_opportunities: List[Opportunity], stapi_backend: TestBackend, stapi_client: TestClient, ): - stapi_backend._products = mock_products + stapi_backend._products = products stapi_backend._opportunities = mock_umbra_spotlight_opportunities now = datetime.now(UTC) @@ -24,7 +24,7 @@ def test_search_opportunities_response( end = start + timedelta(days=5) # Create mock products and opportunities for the test - mock_products[0].id = product_id + products[0].id = product_id # Prepare the request payload request_payload = { diff --git a/tests/product_test.py b/tests/product_test.py index 690074d..3a9125d 100644 --- a/tests/product_test.py +++ b/tests/product_test.py @@ -9,6 +9,7 @@ from .warnings import StapiSpecWarning + def test_products_response(stapi_client: TestClient): res = stapi_client.get("/products") From 571f86a1e64dc5acbc5d173c7d25d6253993f6b5 Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Wed, 23 Oct 2024 12:41:28 -0400 Subject: [PATCH 07/27] adds product router and backend --- src/stapi_fastapi/backends/__init__.py | 0 src/stapi_fastapi/backends/product_backend.py | 35 ++++ src/stapi_fastapi/backends/root_backend.py | 26 +++ src/stapi_fastapi/exceptions.py | 3 + src/stapi_fastapi/models/product.py | 38 ++-- src/stapi_fastapi/routers/__init__.py | 0 src/stapi_fastapi/routers/product_router.py | 102 +++++++++++ src/stapi_fastapi/routers/root_router.py | 163 ++++++++++++++++++ 8 files changed, 339 insertions(+), 28 deletions(-) create mode 100644 src/stapi_fastapi/backends/__init__.py create mode 100644 src/stapi_fastapi/backends/product_backend.py create mode 100644 src/stapi_fastapi/backends/root_backend.py create mode 100644 src/stapi_fastapi/routers/__init__.py create mode 100644 src/stapi_fastapi/routers/product_router.py create mode 100644 src/stapi_fastapi/routers/root_router.py diff --git a/src/stapi_fastapi/backends/__init__.py b/src/stapi_fastapi/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/stapi_fastapi/backends/product_backend.py b/src/stapi_fastapi/backends/product_backend.py new file mode 100644 index 0000000..f998cfa --- /dev/null +++ b/src/stapi_fastapi/backends/product_backend.py @@ -0,0 +1,35 @@ +from typing import Protocol + +from fastapi import Request + +from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest +from stapi_fastapi.models.order import Order +from stapi_fastapi.models.product import Product + + +class ProductBackend(Protocol): + def product(self, product_id: str, request: Request) -> Product | None: + """ + Return the product identified by `product_id` or `None` if it isn't + supported. + """ + + async def search_opportunities( + self, product_id: str, search: OpportunityRequest, request: Request + ) -> list[Opportunity]: + """ + Search for ordering opportunities for the given search parameters. + + Backends must validate search constraints and raise + `stapi_fastapi.backend.exceptions.ConstraintsException` if not valid. + """ + + async def create_order( + self, product_id: str, search: OpportunityRequest, request: Request + ) -> Order: + """ + Create a new order. + + Backends must validate order payload and raise + `stapi_fastapi.backend.exceptions.ConstraintsException` if not valid. + """ diff --git a/src/stapi_fastapi/backends/root_backend.py b/src/stapi_fastapi/backends/root_backend.py new file mode 100644 index 0000000..4d28fc6 --- /dev/null +++ b/src/stapi_fastapi/backends/root_backend.py @@ -0,0 +1,26 @@ +from typing import Protocol + +from fastapi import Request + +from stapi_fastapi.models.order import Order +from stapi_fastapi.models.product import Product + + +class RootBackend(Protocol): + def products(self, request: Request) -> list[Product]: + """ + Return a list of supported products. + """ + + def orders(self, request: Request) -> list[Order]: + """ + Return a list of existing orders. + """ + + async def get_order(self, order_id: str, request: Request) -> Order: + """ + Get details for order with `order_id`. + + Backends must raise `stapi_fastapi.backend.exceptions.NotFoundException` + if not found or access denied. + """ diff --git a/src/stapi_fastapi/exceptions.py b/src/stapi_fastapi/exceptions.py index 9d7fe39..278143d 100644 --- a/src/stapi_fastapi/exceptions.py +++ b/src/stapi_fastapi/exceptions.py @@ -1,10 +1,13 @@ from typing import Any, Mapping + from fastapi import HTTPException + class StapiException(HTTPException): def __init__(self, status_code: int, detail: str) -> None: super().__init__(status_code, detail) + class ConstraintsException(Exception): detail: Mapping[str, Any] | None diff --git a/src/stapi_fastapi/models/product.py b/src/stapi_fastapi/models/product.py index 8e1b064..7d6f3d7 100644 --- a/src/stapi_fastapi/models/product.py +++ b/src/stapi_fastapi/models/product.py @@ -1,13 +1,11 @@ from enum import Enum -from typing import Literal, Optional -from abc import ABC, abstractmethod -from fastapi import Request +from typing import Literal, Optional, Self from pydantic import AnyHttpUrl, BaseModel, Field +from stapi_fastapi.backends.product_backend import ProductBackend from stapi_fastapi.models.shared import Link -from stapi_fastapi.models.opportunity import Opportunity, OpportunityProperties, OpportunityRequest -from stapi_fastapi.models.order import Order +from stapi_fastapi.types.json_schema_model import JsonSchemaModel class ProviderRole(str, Enum): @@ -24,7 +22,7 @@ class Provider(BaseModel): url: AnyHttpUrl -class Product(BaseModel, ABC): +class Product(BaseModel): type: Literal["Product"] = "Product" conformsTo: list[str] = Field(default_factory=list) id: str @@ -34,28 +32,12 @@ class Product(BaseModel, ABC): license: str providers: list[Provider] = Field(default_factory=list) links: list[Link] - parameters: OpportunityProperties - - @abstractmethod - def search_opportunities(self, search: OpportunityRequest, request: Request - ) -> list[Opportunity]: - """ - Search for ordering opportunities for the given search parameters. - - Backends must validate search constraints and raise - `stapi_fastapi.backend.exceptions.ConstraintsException` if not valid. - """ - ... - - @abstractmethod - def create_order(self, search: OpportunityRequest, request: Request) -> Order: - """ - Create a new order. - - Backends must validate order payload and raise - `stapi_fastapi.backend.exceptions.ConstraintsException` if not valid. - """ - ... + constraints: JsonSchemaModel + + def __init__(self: Self, backend: ProductBackend, *args, **kwargs): + super().__init__(*args, **kwargs) + self.backend = backend + class ProductsCollection(BaseModel): type: Literal["ProductCollection"] = "ProductCollection" diff --git a/src/stapi_fastapi/routers/__init__.py b/src/stapi_fastapi/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py new file mode 100644 index 0000000..81c56b5 --- /dev/null +++ b/src/stapi_fastapi/routers/product_router.py @@ -0,0 +1,102 @@ +# Generic product router factory +from typing import Self + +from fastapi import APIRouter, HTTPException, Request, status +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse + +from stapi_fastapi.constants import TYPE_GEOJSON +from stapi_fastapi.exceptions import ConstraintsException +from stapi_fastapi.models.opportunity import ( + OpportunityCollection, + OpportunityRequest, +) +from stapi_fastapi.models.product import Product +from stapi_fastapi.models.shared import Link +from stapi_fastapi.types.json_schema_model import JsonSchemaModel + +""" +/products[MainRouter]/opportunities +/products[MainRouter]/parameters +/products[MainRouter]/order +""" + + +class ProductRouter(APIRouter): + def __init__( + self: Self, + product: Product, + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.product = product + + self.add_api_route( + path="/opportunities", + endpoint=self.search_opportunities, + methods=["POST"], + summary="Search Opportunities for the product", + ) + + self.add_api_route( + path="/constraints", + endpoint=self.get_product_constraints, + methods=["GET"], + summary="Get constraints for the product", + ) + + self.add_api_route( + path="/order", + endpoint=self.create_order, + methods=["POST"], + summary="Create an order for the product", + ) + + async def search_opportunities( + self, search: OpportunityRequest, request: Request + ) -> OpportunityCollection: + """ + Explore the opportunities available for a particular set of constraints + """ + try: + opportunities = await self.product.backend.search_opportunities( + search, request + ) + except ConstraintsException as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail) + return JSONResponse( + jsonable_encoder(OpportunityCollection(features=opportunities)), + media_type=TYPE_GEOJSON, + ) + + async def get_product_constraints(self: Self, request: Request) -> JsonSchemaModel: + """ + Return supported constraints of a specific product + """ + return { + "product_id": self.product.product_id, + "constraints": self.product.constraints, + } + + async def create_order( + self, payload: OpportunityRequest, request: Request + ) -> JSONResponse: + """ + Create a new order. + """ + try: + order = await self.product.backend.create_order(payload, request) + except ConstraintsException as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail) + + location = str( + request.url_for(f"{self.NAME_PREFIX}:get-order", order_id=order.id) + ) + order.links.append(Link(href=location, rel="self", type=TYPE_GEOJSON)) + return JSONResponse( + jsonable_encoder(order, exclude_unset=True), + status.HTTP_201_CREATED, + {"Location": location}, + TYPE_GEOJSON, + ) diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py new file mode 100644 index 0000000..048feab --- /dev/null +++ b/src/stapi_fastapi/routers/root_router.py @@ -0,0 +1,163 @@ +from typing import Self + +from fastapi import APIRouter, HTTPException, Request, status +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse + +from stapi_fastapi.backends.root_backend import RootBackend +from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON +from stapi_fastapi.exceptions import NotFoundException +from stapi_fastapi.models.order import Order +from stapi_fastapi.models.product import Product, ProductsCollection +from stapi_fastapi.models.root import RootResponse +from stapi_fastapi.models.shared import Link +from stapi_fastapi.routers.product_router import ProductRouter + + +class RootRouter(APIRouter): + def __init__( + self: Self, + backend: RootBackend, + name: str = "root", + openapi_endpoint_name="openapi", + docs_endpoint_name="swagger_ui_html", + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.backend = backend + self.name = name + self.openapi_endpoint_name = openapi_endpoint_name + self.docs_endpoint_name = docs_endpoint_name + + self.product_routers: dict[str, ProductRouter] = {} + + self.router.add_api_route( + "/", + self.root, + methods=["GET"], + name=f"{self.NAME_PREFIX}:root", + tags=["Root"], + ) + + self.router.add_api_route( + "/products", + self.products, + methods=["GET"], + name=f"{self.NAME_PREFIX}:list-products", + tags=["Product"], + ) + + self.router.add_api_route( + "/orders", + self.orders, + methods=["GET"], + name=f"{self.NAME_PREFIX}:list-orders", + tags=["Order"], + ) + + self.add_api_route( + "/orders/{order_id}", + self.get_order, + methods=["GET"], + name=f"{self.name}:get-order", + tags=["Orders"], + ) + + def root(self, request: Request) -> RootResponse: + return RootResponse( + links=[ + Link( + href=str(request.url_for(f"{self.NAME_PREFIX}:root")), + rel="self", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(f"{self.NAME_PREFIX}:list-products")), + rel="products", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(f"{self.NAME_PREFIX}:list-orders")), + rel="orders", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(self.openapi_endpoint_name)), + rel="service-description", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(self.docs_endpoint_name)), + rel="service-docs", + type="text/html", + ), + ] + ) + + def products(self, request: Request) -> ProductsCollection: + products: list[Product] = [] + for product_router in self.product_routers: + product = product_router.product + products.append(product) + product.links.append( + Link( + href=str( + request.url_for( + f"{self.NAME_PREFIX}:get-product", product_id=product.id + ) + ), + rel="self", + type=TYPE_JSON, + ) + ) + return ProductsCollection( + products=products, + links=[ + Link( + href=str(request.url_for(f"{self.NAME_PREFIX}:list-products")), + rel="self", + type=TYPE_JSON, + ) + ], + ) + + def orders(self, request: Request) -> list[Order]: + orders = self.backend.orders(request) + for order in orders: + order.links.append( + Link( + href=str( + request.url_for( + f"{self.NAME_PREFIX}:get-order", order_id=order.id + ) + ), + rel="self", + type=TYPE_JSON, + ) + ) + return list[Order] + + async def get_order(self, order_id: str, request: Request) -> Order: + """ + Get details for order with `order_id`. + """ + try: + order = await self.backend.get_order(order_id, request) + except NotFoundException as exc: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="not found") from exc + + order.links.append(Link(href=str(request.url), rel="self", type=TYPE_GEOJSON)) + + return JSONResponse( + jsonable_encoder(order, exclude_unset=True), + status.HTTP_200_OK, + media_type=TYPE_GEOJSON, + ) + + def add_product_router(self, product_router: ProductRouter): + # Give the include a prefix from the product router + self.include_router( + product_router, prefix=f"/products/{product_router.product.id}" + ) + self.product_routers[product_router.product.id] = product_router From 91f8b65f4b7817e944c4f68df2611a946c028d24 Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Wed, 30 Oct 2024 17:43:01 -0400 Subject: [PATCH 08/27] wip: splits backend and router for root and product. Co-authored-by: Jarrett Keifer --- src/stapi_fastapi/backends/product_backend.py | 22 +-- src/stapi_fastapi/backends/root_backend.py | 10 +- src/stapi_fastapi/models/opportunity.py | 17 +- src/stapi_fastapi/models/product.py | 39 ++++- src/stapi_fastapi/models/shared.py | 11 +- src/stapi_fastapi/routers/product_router.py | 39 ++++- src/stapi_fastapi/routers/root_router.py | 71 ++++----- src/stapi_fastapi/types/datetime_interval.py | 4 +- src/stapi_fastapi/umbra_spotlight_router.py | 4 - tests/{backend => }/backend.py | 46 +++--- tests/backend/__init__.py | 3 - tests/conftest.py | 148 ++++++++---------- tests/opportunity_test.py | 37 +++-- tests/order_test.py | 84 +++++----- tests/product_test.py | 17 +- 15 files changed, 277 insertions(+), 275 deletions(-) delete mode 100644 src/stapi_fastapi/umbra_spotlight_router.py rename tests/{backend => }/backend.py (66%) delete mode 100644 tests/backend/__init__.py diff --git a/src/stapi_fastapi/backends/product_backend.py b/src/stapi_fastapi/backends/product_backend.py index f998cfa..892f488 100644 --- a/src/stapi_fastapi/backends/product_backend.py +++ b/src/stapi_fastapi/backends/product_backend.py @@ -1,21 +1,20 @@ +from __future__ import annotations + from typing import Protocol from fastapi import Request +import stapi_fastapi from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest from stapi_fastapi.models.order import Order -from stapi_fastapi.models.product import Product class ProductBackend(Protocol): - def product(self, product_id: str, request: Request) -> Product | None: - """ - Return the product identified by `product_id` or `None` if it isn't - supported. - """ - async def search_opportunities( - self, product_id: str, search: OpportunityRequest, request: Request + self, + product: stapi_fastapi.models.product.Product, + search: OpportunityRequest, + request: Request, ) -> list[Opportunity]: """ Search for ordering opportunities for the given search parameters. @@ -23,9 +22,13 @@ async def search_opportunities( Backends must validate search constraints and raise `stapi_fastapi.backend.exceptions.ConstraintsException` if not valid. """ + ... async def create_order( - self, product_id: str, search: OpportunityRequest, request: Request + self, + product: stapi_fastapi.models.product.Product, + search: OpportunityRequest, + request: Request, ) -> Order: """ Create a new order. @@ -33,3 +36,4 @@ async def create_order( Backends must validate order payload and raise `stapi_fastapi.backend.exceptions.ConstraintsException` if not valid. """ + ... diff --git a/src/stapi_fastapi/backends/root_backend.py b/src/stapi_fastapi/backends/root_backend.py index 4d28fc6..79887bf 100644 --- a/src/stapi_fastapi/backends/root_backend.py +++ b/src/stapi_fastapi/backends/root_backend.py @@ -3,19 +3,14 @@ from fastapi import Request from stapi_fastapi.models.order import Order -from stapi_fastapi.models.product import Product class RootBackend(Protocol): - def products(self, request: Request) -> list[Product]: - """ - Return a list of supported products. - """ - - def orders(self, request: Request) -> list[Order]: + async def get_orders(self, request: Request) -> list[Order]: """ Return a list of existing orders. """ + ... async def get_order(self, order_id: str, request: Request) -> Order: """ @@ -24,3 +19,4 @@ async def get_order(self, order_id: str, request: Request) -> Order: Backends must raise `stapi_fastapi.backend.exceptions.NotFoundException` if not found or access denied. """ + ... diff --git a/src/stapi_fastapi/models/opportunity.py b/src/stapi_fastapi/models/opportunity.py index 12cded1..60c1faf 100644 --- a/src/stapi_fastapi/models/opportunity.py +++ b/src/stapi_fastapi/models/opportunity.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional, TypeVar, Generic +from typing import Literal, Optional from geojson_pydantic import Feature, FeatureCollection from geojson_pydantic.geometries import Geometry @@ -8,14 +8,13 @@ from stapi_fastapi.types.datetime_interval import DatetimeInterval from stapi_fastapi.types.filter import CQL2Filter -# Generic type definition for Opportunity Properties -T = TypeVar("T") # Copied and modified from https://github.com/stac-utils/stac-pydantic/blob/main/stac_pydantic/item.py#L11 -class OpportunityProperties(BaseModel, Generic[T]): +class OpportunityProperties(BaseModel): datetime: DatetimeInterval model_config = ConfigDict(extra="allow") + class OpportunityRequest(BaseModel): datetime: DatetimeInterval geometry: Geometry @@ -23,14 +22,12 @@ class OpportunityRequest(BaseModel): filter: Optional[CQL2Filter] = None model_config = ConfigDict(strict=True) -# Generic type definition for Opportunity -P = TypeVar("P", bound=OpportunityProperties) -K = TypeVar("K", bound=Geometry) -# Each product implements its own opportunity model -class Opportunity(Feature[K, P], Generic[K, P]): +# GENERIC: Each product needs an opportunity model (constraints/parameters) +class Opportunity(Feature[Geometry, OpportunityProperties]): type: Literal["Feature"] = "Feature" links: list[Link] = [] -class OpportunityCollection(FeatureCollection[Opportunity[K, P]], Generic[K, P]): + +class OpportunityCollection(FeatureCollection[Opportunity]): type: Literal["FeatureCollection"] = "FeatureCollection" diff --git a/src/stapi_fastapi/models/product.py b/src/stapi_fastapi/models/product.py index 7d6f3d7..e15668a 100644 --- a/src/stapi_fastapi/models/product.py +++ b/src/stapi_fastapi/models/product.py @@ -1,11 +1,14 @@ +from __future__ import annotations + +from copy import deepcopy from enum import Enum from typing import Literal, Optional, Self from pydantic import AnyHttpUrl, BaseModel, Field from stapi_fastapi.backends.product_backend import ProductBackend +from stapi_fastapi.models.opportunity import OpportunityProperties from stapi_fastapi.models.shared import Link -from stapi_fastapi.types.json_schema_model import JsonSchemaModel class ProviderRole(str, Enum): @@ -23,7 +26,7 @@ class Provider(BaseModel): class Product(BaseModel): - type: Literal["Product"] = "Product" + type_: Literal["Product"] = Field(default="Product", alias="type") conformsTo: list[str] = Field(default_factory=list) id: str title: str = "" @@ -32,11 +35,37 @@ class Product(BaseModel): license: str providers: list[Provider] = Field(default_factory=list) links: list[Link] - constraints: JsonSchemaModel - def __init__(self: Self, backend: ProductBackend, *args, **kwargs): + # we don't want to include these in the model fields + _constraints: type[OpportunityProperties] + _backend: ProductBackend + + def __init__( + self: Self, + *args, + backend: ProductBackend, + constraints: type[OpportunityProperties], + **kwargs, + ) -> None: super().__init__(*args, **kwargs) - self.backend = backend + self._backend = backend + self._constraints = constraints + + @property + def backend(self: Self) -> ProductBackend: + return self._backend + + @property + def constraints(self: Self) -> type[OpportunityProperties]: + return self._constraints + + def with_links(self: Self, links: list[Link] | None = None) -> Self: + if not links: + return self + + new = deepcopy(self) + new.links.extend(links) + return new class ProductsCollection(BaseModel): diff --git a/src/stapi_fastapi/models/shared.py b/src/stapi_fastapi/models/shared.py index c741bd9..c013658 100644 --- a/src/stapi_fastapi/models/shared.py +++ b/src/stapi_fastapi/models/shared.py @@ -1,4 +1,4 @@ -from typing import Any, Optional, Union +from typing import Any, Optional, Self, Union from pydantic import AnyUrl, BaseModel, ConfigDict @@ -6,7 +6,7 @@ class Link(BaseModel): href: AnyUrl rel: str - type: Optional[str] = None + type: str | None = None title: Optional[str] = None method: Optional[str] = None headers: Optional[dict[str, Union[str, list[str]]]] = None @@ -14,6 +14,13 @@ class Link(BaseModel): model_config = ConfigDict(extra="allow") + def model_dump_json(self: Self, *args, **kwargs) -> bytes: + # TODO: this isn't working as expected and we get nulls in the output + # maybe need to override python dump too + # forcing the call to model_dump_json to exclude unset fields by default + kwargs["exclude_unset"] = kwargs.get("exclude_unset", True) + return super().model_dump_json(*args, **kwargs) + class HTTPException(BaseModel): detail: str diff --git a/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py index 81c56b5..950f13f 100644 --- a/src/stapi_fastapi/routers/product_router.py +++ b/src/stapi_fastapi/routers/product_router.py @@ -1,11 +1,14 @@ # Generic product router factory +from __future__ import annotations + from typing import Self from fastapi import APIRouter, HTTPException, Request, status from fastapi.encoders import jsonable_encoder from fastapi.responses import JSONResponse -from stapi_fastapi.constants import TYPE_GEOJSON +import stapi_fastapi +from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON from stapi_fastapi.exceptions import ConstraintsException from stapi_fastapi.models.opportunity import ( OpportunityCollection, @@ -26,15 +29,26 @@ class ProductRouter(APIRouter): def __init__( self: Self, product: Product, + root_router: stapi_fastapi.routers.RootRouter, *args, **kwargs, ): super().__init__(*args, **kwargs) self.product = product + self.root_router = root_router + + self.add_api_route( + path="", + endpoint=self.get_product, + name=f"{self.root_router.name}:{self.product.id}:get-product", + methods=["GET"], + summary="Retrieve this product", + ) self.add_api_route( path="/opportunities", endpoint=self.search_opportunities, + name=f"{self.root_router.name}:{self.product.id}:search-opportunities", methods=["POST"], summary="Search Opportunities for the product", ) @@ -42,6 +56,7 @@ def __init__( self.add_api_route( path="/constraints", endpoint=self.get_product_constraints, + name=f"{self.root_router.name}:{self.product.id}:get-constraints", methods=["GET"], summary="Get constraints for the product", ) @@ -49,10 +64,26 @@ def __init__( self.add_api_route( path="/order", endpoint=self.create_order, + name=f"{self.root_router.name}:{self.product.id}:create-order", methods=["POST"], summary="Create an order for the product", ) + def get_product(self, request: Request) -> Product: + return self.product.with_links( + links=[ + Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:get-product", + ), + ), + rel="self", + type=TYPE_JSON, + ), + ], + ) + async def search_opportunities( self, search: OpportunityRequest, request: Request ) -> OpportunityCollection: @@ -75,7 +106,7 @@ async def get_product_constraints(self: Self, request: Request) -> JsonSchemaMod Return supported constraints of a specific product """ return { - "product_id": self.product.product_id, + "product.id": self.product.product.id, "constraints": self.product.constraints, } @@ -90,9 +121,7 @@ async def create_order( except ConstraintsException as exc: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail) - location = str( - request.url_for(f"{self.NAME_PREFIX}:get-order", order_id=order.id) - ) + location = self.root_router.generate_order_href(request, order.id) order.links.append(Link(href=location, rel="self", type=TYPE_GEOJSON)) return JSONResponse( jsonable_encoder(order, exclude_unset=True), diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index 048feab..da8fe6d 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -32,27 +32,27 @@ def __init__( self.product_routers: dict[str, ProductRouter] = {} - self.router.add_api_route( - "/", - self.root, + self.add_api_route( + "$", + self.get_root, methods=["GET"], - name=f"{self.NAME_PREFIX}:root", + name=f"{self.name}:root", tags=["Root"], ) - self.router.add_api_route( + self.add_api_route( "/products", - self.products, + self.get_products, methods=["GET"], - name=f"{self.NAME_PREFIX}:list-products", + name=f"{self.name}:list-products", tags=["Product"], ) - self.router.add_api_route( + self.add_api_route( "/orders", - self.orders, + self.get_orders, methods=["GET"], - name=f"{self.NAME_PREFIX}:list-orders", + name=f"{self.name}:list-orders", tags=["Order"], ) @@ -64,21 +64,21 @@ def __init__( tags=["Orders"], ) - def root(self, request: Request) -> RootResponse: + def get_root(self, request: Request) -> RootResponse: return RootResponse( links=[ Link( - href=str(request.url_for(f"{self.NAME_PREFIX}:root")), + href=str(request.url_for(f"{self.name}:root")), rel="self", type=TYPE_JSON, ), Link( - href=str(request.url_for(f"{self.NAME_PREFIX}:list-products")), + href=str(request.url_for(f"{self.name}:list-products")), rel="products", type=TYPE_JSON, ), Link( - href=str(request.url_for(f"{self.NAME_PREFIX}:list-orders")), + href=str(request.url_for(f"{self.name}:list-orders")), rel="orders", type=TYPE_JSON, ), @@ -95,42 +95,25 @@ def root(self, request: Request) -> RootResponse: ] ) - def products(self, request: Request) -> ProductsCollection: - products: list[Product] = [] - for product_router in self.product_routers: - product = product_router.product - products.append(product) - product.links.append( - Link( - href=str( - request.url_for( - f"{self.NAME_PREFIX}:get-product", product_id=product.id - ) - ), - rel="self", - type=TYPE_JSON, - ) - ) + def get_products(self, request: Request) -> ProductsCollection: return ProductsCollection( - products=products, + products=[pr.get_product(request) for pr in self.product_routers.values()], links=[ Link( - href=str(request.url_for(f"{self.NAME_PREFIX}:list-products")), + href=str(request.url_for(f"{self.name}:list-products")), rel="self", type=TYPE_JSON, ) ], ) - def orders(self, request: Request) -> list[Order]: - orders = self.backend.orders(request) + async def get_orders(self, request: Request) -> list[Order]: + orders = await self.backend.orders(request) for order in orders: order.links.append( Link( href=str( - request.url_for( - f"{self.NAME_PREFIX}:get-order", order_id=order.id - ) + request.url_for(f"{self.name}:get-order", order_id=order.id) ), rel="self", type=TYPE_JSON, @@ -138,7 +121,7 @@ def orders(self, request: Request) -> list[Order]: ) return list[Order] - async def get_order(self, order_id: str, request: Request) -> Order: + async def get_order(self: Self, order_id: str, request: Request) -> Order: """ Get details for order with `order_id`. """ @@ -155,9 +138,11 @@ async def get_order(self, order_id: str, request: Request) -> Order: media_type=TYPE_GEOJSON, ) - def add_product_router(self, product_router: ProductRouter): + def add_product(self: Self, product: Product) -> None: # Give the include a prefix from the product router - self.include_router( - product_router, prefix=f"/products/{product_router.product.id}" - ) - self.product_routers[product_router.product.id] = product_router + product_router = ProductRouter(product, self) + self.include_router(product_router, prefix=f"/products/{product.id}") + self.product_routers[product.id] = product_router + + def generate_order_href(self: Self, request: Request, order_id: str) -> str: + return str(request.url_for(f"{self.name}:get-order", order_id=order_id)) diff --git a/src/stapi_fastapi/types/datetime_interval.py b/src/stapi_fastapi/types/datetime_interval.py index 3cedc7a..80e9d7f 100644 --- a/src/stapi_fastapi/types/datetime_interval.py +++ b/src/stapi_fastapi/types/datetime_interval.py @@ -10,10 +10,10 @@ ) -def validate_before(value: Any): +def validate_before(value: Any) -> tuple[datetime, datetime]: if isinstance(value, str): start, end = value.split("/", 1) - return (start, end) + return (datetime.fromisoformat(start), datetime.fromisoformat(end)) return value diff --git a/src/stapi_fastapi/umbra_spotlight_router.py b/src/stapi_fastapi/umbra_spotlight_router.py deleted file mode 100644 index db3e0ed..0000000 --- a/src/stapi_fastapi/umbra_spotlight_router.py +++ /dev/null @@ -1,4 +0,0 @@ -from routers.products_router import create_products_router - -# Create a router for electronics using the base factory function -umbra_spotlight_router = create_products_router("umbra-spotlight-1") diff --git a/tests/backend/backend.py b/tests/backend.py similarity index 66% rename from tests/backend/backend.py rename to tests/backend.py index 76e086f..58067e9 100644 --- a/tests/backend/backend.py +++ b/tests/backend.py @@ -2,43 +2,44 @@ from uuid import uuid4 from fastapi import Request +from stapi_fastapi.backends.product_backend import ProductBackend from stapi_fastapi.exceptions import ConstraintsException, NotFoundException from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest from stapi_fastapi.models.order import Order from stapi_fastapi.models.product import Product -class TestBackend: - _products: list[Product] = [] - _opportunities: list[Opportunity] = [] - _allowed_payloads: list[OpportunityRequest] = [] +class TestRootBackend: _orders: Mapping[str, Order] = {} - def products(self, request: Request) -> list[Product]: + async def get_orders(self, request: Request) -> list[Order]: """ - Return a list of supported products. + Show all orders. """ - return self._products + return list(self._orders.values()) - def product(self, product_id: str, request: Request) -> Product | None: + async def get_order(self, order_id: str, request: Request) -> Order: """ - Return the product identified by `product_id` or `None` if it isn't - supported. + Show details for order with `order_id`. """ try: - return next( - (product for product in self._products if product.id == product_id) - ) - except StopIteration as exc: - raise NotFoundException() from exc + return self._orders[order_id] + except KeyError: + raise NotFoundException() + + +class TestProductBackend(ProductBackend): + _opportunities: list[Opportunity] = [] + _allowed_payloads: list[OpportunityRequest] = [] + _orders: Mapping[str, Order] = {} async def search_opportunities( - self, search: OpportunityRequest, request: Request + self, product: Product, search: OpportunityRequest, request: Request ) -> list[Opportunity]: return [o.model_copy(update=search.model_dump()) for o in self._opportunities] async def create_order( - self, payload: OpportunityRequest, request: Request + self, product: Product, payload: OpportunityRequest, request: Request ) -> Order: """ Create a new order. @@ -51,19 +52,10 @@ async def create_order( properties={ "filter": payload.filter, "datetime": payload.datetime, - "product_id": payload.product_id, + "product_id": product.id, }, links=[], ) self._orders[order.id] = order return order raise ConstraintsException("not allowed") - - async def get_order(self, order_id: str, request: Request) -> Order: - """ - Show details for order with `order_id`. - """ - try: - return self._orders[order_id] - except KeyError: - raise NotFoundException() diff --git a/tests/backend/__init__.py b/tests/backend/__init__.py deleted file mode 100644 index a404262..0000000 --- a/tests/backend/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .backend import TestBackend - -__all__ = ["TestBackend"] diff --git a/tests/conftest.py b/tests/conftest.py index 70ef177..6581c77 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,74 +1,84 @@ -from typing import Callable, Generator, TypeVar, List +from collections.abc import Iterator +from datetime import UTC, datetime, timedelta, timezone +from typing import Callable, Generator, List, TypeVar from urllib.parse import urljoin from uuid import uuid4 + import pytest -from datetime import datetime, timezone, timedelta, UTC +from fastapi import FastAPI +from fastapi.testclient import TestClient from geojson_pydantic import Point -from pytest import Parser, fixture - +from pytest import fixture +from stapi_fastapi.models.opportunity import ( + Opportunity, + OpportunityProperties, + OpportunityRequest, +) from stapi_fastapi.models.product import Product, Provider, ProviderRole -from stapi_fastapi.models.shared import Link -from stapi_fastapi.models.opportunity import OpportunityProperties, Opportunity, OpportunityRequest -from stapi_fastapi.types.datetime_interval import DatetimeInterval -from collections.abc import Iterator -from fastapi.testclient import TestClient -from stapi_fastapi.products_router import ProductRouter -from stapi_fastapi.main_router import MainRouter +# from stapi_fastapi.main_router import MainRouter +from stapi_fastapi.routers.root_router import RootRouter + +from .backend import TestProductBackend, TestRootBackend -from .backend import TestBackend # Define concrete classes for Products to mock -class UmbraSpotlight(Product): - def search_opportunities(self, search, request): +class TestSpotlight(Product): + def search_opportunities(self, product, search, request): return [] - def create_order(self, search, request): + + def create_order(self, product, search, request): return [] -class UmbraSpotlightProperties(OpportunityProperties): - datetime: DatetimeInterval + +class TestSpotlightProperties(OpportunityProperties): + off_nadir: int + @pytest.fixture -def mock_product_umbra_spotlight(mock_provider_umbra: Provider) -> Product: - """Fixture for a mock Umbra Spotlight product.""" +def mock_product_test_spotlight(mock_provider_test: Provider) -> Product: + """Fixture for a mock Test Spotlight product.""" now = datetime.now(timezone.utc) # Use timezone-aware datetime start = now end = start + timedelta(days=5) datetime_interval = f"{start.isoformat()}/{end.isoformat()}" - return UmbraSpotlight( - id="umbra-spotlight", - title="Umbra Spotlight Product", - description="Test product for umbra spotlight", + return Product( + id="test-spotlight", + title="Test Spotlight Product", + description="Test product for test spotlight", license="CC-BY-4.0", - keywords=["test", "umbra", "satellite"], - providers=[mock_provider_umbra], - links=[ - Link(href="http://example.com", rel="self"), - Link(href="http://example.com/catalog", rel="parent"), - ], - parameters=UmbraSpotlightProperties( - datetime=datetime_interval, - off_nadir=20, - ) + keywords=["test", "satellite"], + providers=[mock_provider_test], + links=[], + constraints=TestSpotlightProperties, + backend=TestProductBackend, ) + @pytest.fixture(scope="session") def base_url() -> Iterator[str]: yield "http://stapiserver" @pytest.fixture -def stapi_backend() -> Iterator[TestBackend]: - yield TestBackend() +def product_backend() -> Iterator[TestProductBackend]: + yield TestProductBackend() + @pytest.fixture -def stapi_client(stapi_backend, mock_product_umbra_spotlight, base_url: str) -> Iterator[TestClient]: - app = MainRouter(stapi_backend) +def root_backend() -> Iterator[TestRootBackend]: + yield TestRootBackend() - app.add_product_router( - ProductRouter(mock_product_umbra_spotlight) - ) + +@pytest.fixture +def stapi_client( + root_backend, mock_product_test_spotlight, base_url: str +) -> Iterator[TestClient]: + root_router = RootRouter(root_backend) + root_router.add_product(mock_product_test_spotlight) + app = FastAPI() + app.include_router(root_router, prefix="") yield TestClient(app, base_url=f"{base_url}") @@ -85,36 +95,8 @@ def url_for(value: str) -> str: @pytest.fixture -def products(mock_product_umbra_spotlight) -> Iterator[list[Product]]: - now = datetime.now(timezone.utc) # Use timezone-aware datetime - start = now - end = start + timedelta(days=5) - datetime_interval = f"{start.isoformat()}/{end.isoformat()}" - - yield [ - UmbraSpotlight( - id="mock:standard", - description="Mock backend's standard product", - license="CC0-1.0", - providers=[ - Provider( - name="ACME", - roles=[ - ProviderRole.licensor, - ProviderRole.producer, - ProviderRole.processor, - ProviderRole.host, - ], - url="http://acme.example.com", - ) - ], - parameters=UmbraSpotlightProperties( - datetime=datetime_interval, - off_nadir=20, - ), - links=[], - ) - ] +def products(mock_product_test_spotlight) -> Iterator[Product]: + return [mock_product_test_spotlight] @pytest.fixture @@ -142,19 +124,11 @@ def allowed_payloads(products: list[Product]) -> Iterator[list[OpportunityReques ), ] + T = TypeVar("T") YieldFixture = Generator[T, None, None] -def pytest_addoption(parser: Parser): - parser.addoption( - "--stapi-backend", - action="store", - default="stapi_fastapi_test_backend:TestBackend", - ) - parser.addoption("--stapi-prefix", action="store", default="/stapi") - parser.addoption("--stapi-product-id", action="store", default="mock:standard") - @fixture(scope="session") def base_url() -> YieldFixture[str]: @@ -171,18 +145,20 @@ def url_for(value: str) -> str: yield url_for + @pytest.fixture -def mock_provider_umbra() -> Provider: +def mock_provider_test() -> Provider: return Provider( - name="Umbra Provider", - description="A provider for Umbra data", + name="Test Provider", + description="A provider for Test data", roles=[ProviderRole.producer], # Example role - url="https://umbra-provider.example.com" # Must be a valid URL + url="https://test-provider.example.com", # Must be a valid URL ) + @pytest.fixture -def mock_umbra_spotlight_opportunities() -> List[Opportunity]: - """Fixture to create mock data for Opportunities for `umbra-spotlight-1`.""" +def mock_test_spotlight_opportunities() -> List[Opportunity]: + """Fixture to create mock data for Opportunities for `test-spotlight-1`.""" now = datetime.now(timezone.utc) # Use timezone-aware datetime start = now end = start + timedelta(days=5) @@ -194,7 +170,7 @@ def mock_umbra_spotlight_opportunities() -> List[Opportunity]: id=str(uuid4()), type="Feature", geometry=Point(type="Point", coordinates=[0, 0]), # Simple point geometry - properties=UmbraSpotlightProperties( + properties=TestSpotlightProperties( datetime=datetime_interval, off_nadir=20, ), diff --git a/tests/opportunity_test.py b/tests/opportunity_test.py index def4820..ce753d3 100644 --- a/tests/opportunity_test.py +++ b/tests/opportunity_test.py @@ -1,30 +1,30 @@ -import pytest -from typing import List +import json from datetime import UTC, datetime, timedelta +from typing import List +import pytest from fastapi.testclient import TestClient from stapi_fastapi.models.opportunity import Opportunity -from stapi_fastapi.models.product import Product -from .backend import TestBackend +from .backend import TestProductBackend +from .datetime_interval_test import rfc3339_strftime -@pytest.mark.parametrize("product_id", ["umbra-spotlight"]) + +@pytest.mark.parametrize("product_id", ["test-spotlight"]) def test_search_opportunities_response( product_id: str, - products: list[Product], - mock_umbra_spotlight_opportunities: List[Opportunity], - stapi_backend: TestBackend, + mock_test_spotlight_opportunities: List[Opportunity], + product_backend: TestProductBackend, stapi_client: TestClient, ): - stapi_backend._products = products - stapi_backend._opportunities = mock_umbra_spotlight_opportunities + product_backend._opportunities = mock_test_spotlight_opportunities now = datetime.now(UTC) start = now end = start + timedelta(days=5) - - # Create mock products and opportunities for the test - products[0].id = product_id + format = "%Y-%m-%dT%H:%M:%S.%f%z" + start_string = rfc3339_strftime(start, format) + end_string = rfc3339_strftime(end, format) # Prepare the request payload request_payload = { @@ -32,6 +32,7 @@ def test_search_opportunities_response( "type": "Point", "coordinates": [0, 0], }, + "datetime": f"{start_string}/{end_string}", "filter": { "op": "and", "args": [ @@ -47,9 +48,15 @@ def test_search_opportunities_response( # Use POST method to send the payload response = stapi_client.post(url, json=request_payload) + print(json.dumps(response.json(), indent=4)) + # Validate response status and structure assert response.status_code == 200, f"Failed for product: {product_id}" - assert isinstance(response.json(), list), "Response should be a list of opportunities" + assert isinstance( + response.json(), list + ), "Response should be a list of opportunities" for opportunity in response.json(): assert "id" in opportunity, "Opportunity item should have an 'id' field" - assert "properties" in opportunity, "Opportunity item should have a 'properties' field" \ No newline at end of file + assert ( + "properties" in opportunity + ), "Opportunity item should have a 'properties' field" diff --git a/tests/order_test.py b/tests/order_test.py index 4a94286..cb9b521 100644 --- a/tests/order_test.py +++ b/tests/order_test.py @@ -1,65 +1,55 @@ from datetime import UTC, datetime, timedelta -from typing import Generator - -from fastapi import status -from fastapi.testclient import TestClient -from httpx import Response -from pytest import fixture -from stapi_fastapi.models.opportunity import OpportunityRequest - -from .backend import TestBackend -from .utils import find_link NOW = datetime.now(UTC) START = NOW END = START + timedelta(days=5) -@fixture -def new_order_response( - stapi_backend: TestBackend, - stapi_client: TestClient, - allowed_payloads: list[OpportunityRequest], -) -> Generator[Response, None, None]: - stapi_backend._allowed_payloads = allowed_payloads +# @fixture +# def new_order_response( +# stapi_backend: TestRootBackend, +# stapi_client: TestClient, +# allowed_payloads: list[OpportunityRequest], +# ) -> Generator[Response, None, None]: +# stapi_backend._allowed_payloads = allowed_payloads - res = stapi_client.post( - "/orders", - json=allowed_payloads[0].model_dump(), - ) +# res = stapi_client.post( +# "/orders", +# json=allowed_payloads[0].model_dump(), +# ) - assert res.status_code == status.HTTP_201_CREATED - assert res.headers["Content-Type"] == "application/geo+json" - yield res +# assert res.status_code == status.HTTP_201_CREATED +# assert res.headers["Content-Type"] == "application/geo+json" +# yield res -def test_new_order_location_header_matches_self_link(new_order_response: Response): - order = new_order_response.json() - assert new_order_response.headers["Location"] == str( - find_link(order["links"], "self")["href"] - ) +# def test_new_order_location_header_matches_self_link(new_order_response: Response): +# order = new_order_response.json() +# assert new_order_response.headers["Location"] == str( +# find_link(order["links"], "self")["href"] +# ) -@fixture -def get_order_response( - stapi_client: TestClient, new_order_response: Response -) -> Generator[Response, None, None]: - order_id = new_order_response.json()["id"] +# @fixture +# def get_order_response( +# stapi_client: TestClient, new_order_response: Response +# ) -> Generator[Response, None, None]: +# order_id = new_order_response.json()["id"] - res = stapi_client.get(f"/orders/{order_id}") - assert res.status_code == status.HTTP_200_OK - assert res.headers["Content-Type"] == "application/geo+json" - yield res +# res = stapi_client.get(f"/orders/{order_id}") +# assert res.status_code == status.HTTP_200_OK +# assert res.headers["Content-Type"] == "application/geo+json" +# yield res -def test_get_order_properties(get_order_response: Response, allowed_payloads): - order = get_order_response.json() +# def test_get_order_properties(get_order_response: Response, allowed_payloads): +# order = get_order_response.json() - assert order["geometry"] == { - "type": "Point", - "coordinates": list(allowed_payloads[0].geometry.coordinates), - } +# assert order["geometry"] == { +# "type": "Point", +# "coordinates": list(allowed_payloads[0].geometry.coordinates), +# } - assert ( - order["properties"]["datetime"] == allowed_payloads[0].model_dump()["datetime"] - ) +# assert ( +# order["properties"]["datetime"] == allowed_payloads[0].model_dump()["datetime"] +# ) diff --git a/tests/product_test.py b/tests/product_test.py index 3a9125d..de6e48e 100644 --- a/tests/product_test.py +++ b/tests/product_test.py @@ -1,15 +1,14 @@ +import json from warnings import warn +import pytest from fastapi import status from fastapi.testclient import TestClient -from stapi_fastapi.models.product import Product -from .backend import TestBackend from .utils import find_link from .warnings import StapiSpecWarning - def test_products_response(stapi_client: TestClient): res = stapi_client.get("/products") @@ -22,16 +21,14 @@ def test_products_response(stapi_client: TestClient): assert isinstance(data["products"], list) +@pytest.mark.parametrize("product_id", ["test-spotlight"]) def test_product_response_self_link( - products: list[Product], - stapi_backend: TestBackend, + product_id: str, stapi_client: TestClient, url_for, ): - stapi_backend._products = products - - res = stapi_client.get("/products/mock:standard") - + res = stapi_client.get(f"/products/{product_id}") + print(json.dumps(res.json(), indent=4)) assert res.status_code == status.HTTP_200_OK assert res.headers["Content-Type"] == "application/json" @@ -41,4 +38,4 @@ def test_product_response_self_link( warn(StapiSpecWarning("GET /products Link[rel=self] should exist")) else: assert link["type"] == "application/json" - assert link["href"] == url_for("/products/mock:standard") + assert link["href"] == url_for(f"/products/{product_id}") From db815d15b84929a4535afe22a11c08581714ccdf Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Thu, 31 Oct 2024 15:57:57 -0400 Subject: [PATCH 09/27] adds test for get_constraints --- src/stapi_fastapi/routers/product_router.py | 5 +---- tests/product_test.py | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py index 950f13f..8621a90 100644 --- a/src/stapi_fastapi/routers/product_router.py +++ b/src/stapi_fastapi/routers/product_router.py @@ -105,10 +105,7 @@ async def get_product_constraints(self: Self, request: Request) -> JsonSchemaMod """ Return supported constraints of a specific product """ - return { - "product.id": self.product.product.id, - "constraints": self.product.constraints, - } + return self.product.constraints async def create_order( self, payload: OpportunityRequest, request: Request diff --git a/tests/product_test.py b/tests/product_test.py index de6e48e..eb89325 100644 --- a/tests/product_test.py +++ b/tests/product_test.py @@ -1,4 +1,3 @@ -import json from warnings import warn import pytest @@ -28,7 +27,6 @@ def test_product_response_self_link( url_for, ): res = stapi_client.get(f"/products/{product_id}") - print(json.dumps(res.json(), indent=4)) assert res.status_code == status.HTTP_200_OK assert res.headers["Content-Type"] == "application/json" @@ -39,3 +37,18 @@ def test_product_response_self_link( else: assert link["type"] == "application/json" assert link["href"] == url_for(f"/products/{product_id}") + + +@pytest.mark.parametrize("product_id", ["test-spotlight"]) +def test_product_constraints_response( + product_id: str, + stapi_client: TestClient, +): + res = stapi_client.get(f"/products/{product_id}/constraints") + assert res.status_code == status.HTTP_200_OK + assert res.headers["Content-Type"] == "application/json" + + data = res.json() + assert "properties" in data + assert "datetime" in data["properties"] + assert "off_nadir" in data["properties"] From f79a5890a164391408b65376feef6120702abd01 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 31 Oct 2024 15:17:51 -0700 Subject: [PATCH 10/27] remove unused modules --- src/stapi_fastapi/backend.py | 45 --------- src/stapi_fastapi/main_router.py | 138 --------------------------- src/stapi_fastapi/products_router.py | 62 ------------ 3 files changed, 245 deletions(-) delete mode 100644 src/stapi_fastapi/backend.py delete mode 100644 src/stapi_fastapi/main_router.py delete mode 100644 src/stapi_fastapi/products_router.py diff --git a/src/stapi_fastapi/backend.py b/src/stapi_fastapi/backend.py deleted file mode 100644 index 9e37462..0000000 --- a/src/stapi_fastapi/backend.py +++ /dev/null @@ -1,45 +0,0 @@ -from typing import Protocol -from fastapi import Request - -from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest -from stapi_fastapi.models.order import Order -from stapi_fastapi.models.product import Product - - -class StapiBackend(Protocol): - def products(self, request: Request) -> list[Product]: - """ - Return a list of supported products. - """ - - def product(self, product_id: str, request: Request) -> Product | None: - """ - Return the product identified by `product_id` or `None` if it isn't - supported. - """ - - async def search_opportunities( - self, search: OpportunityRequest, request: Request - ) -> list[Opportunity]: - """ - Search for ordering opportunities for the given search parameters. - - Backends must validate search constraints and raise - `stapi_fastapi.backend.exceptions.ConstraintsException` if not valid. - """ - - async def create_order(self, search: OpportunityRequest, request: Request) -> Order: - """ - Create a new order. - - Backends must validate order payload and raise - `stapi_fastapi.backend.exceptions.ConstraintsException` if not valid. - """ - - async def get_order(self, order_id: str, request: Request) -> Order: - """ - Get details for order with `order_id`. - - Backends must raise `stapi_fastapi.backend.exceptions.NotFoundException` - if not found or access denied. - """ diff --git a/src/stapi_fastapi/main_router.py b/src/stapi_fastapi/main_router.py deleted file mode 100644 index 448bbae..0000000 --- a/src/stapi_fastapi/main_router.py +++ /dev/null @@ -1,138 +0,0 @@ -from typing import Self, Optional -from fastapi import APIRouter, HTTPException, Request, status -from fastapi.encoders import jsonable_encoder -from fastapi.responses import JSONResponse - -from stapi_fastapi.backend import StapiBackend -from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON -from stapi_fastapi.exceptions import StapiException, ConstraintsException, NotFoundException -from stapi_fastapi.models.opportunity import ( - OpportunityCollection, - OpportunityRequest, -) -from stapi_fastapi.models.order import Order -from stapi_fastapi.models.product import Product, ProductsCollection -from stapi_fastapi.models.root import RootResponse -from stapi_fastapi.models.shared import HTTPException as HTTPExceptionModel -from stapi_fastapi.models.shared import Link -from stapi_fastapi.products_router import ProductRouter - -""" -/products/{component router} # router for each product added to main router -/orders # list all orders -""" -class MainRouter(APIRouter): - - def __init__( - self: Self, - backend: StapiBackend, - name: str = "main", - openapi_endpoint_name: str = "openapi", - docs_endpoint_name: str = "swagger_ui_html", - *args, - **kwargs, - ): - super().__init__(*args, **kwargs) - self.backend = backend - self.name = name - self.openapi_endpoint_name = openapi_endpoint_name - self.docs_endpoint_name = docs_endpoint_name - - self.product_routers: dict[str, ProductRouter] = {} - - self.add_api_route( - "/", - self.root, - methods=["GET"], - name=f"{self.name}:root", - tags=["Root"], - ) - - self.add_api_route( - "/products", - self.products, - methods=["GET","POST"], - name=f"{self.name}:list-products", - tags=["Product"], - ) - - self.add_api_route( - "/orders/{order_id}", - self.get_order, - methods=["GET"], - name=f"{self.name}:get-order", - tags=["Orders"], - ) - - def root(self, request: Request) -> RootResponse: - return RootResponse( - links=[ - Link( - href=str(request.url_for(f"{self.name}:root")), - rel="self", - type=TYPE_JSON, - ), - Link( - href=str(request.url_for(f"{self.name}:list-products")), - rel="products", - type=TYPE_JSON, - ), - Link( - href=str(request.url_for(self.openapi_endpoint_name)), - rel="service-description", - type=TYPE_JSON, - ), - Link( - href=str(request.url_for(self.docs_endpoint_name)), - rel="service-docs", - type="text/html", - ), - ] - ) - - def products(self, request: Request) -> ProductsCollection: - products = self.backend.products(request) - for product in products: - product.links.append( - Link( - href=str( - request.url_for( - f"{self.name}:get-product", product_id=product.id - ) - ), - rel="self", - type=TYPE_JSON, - ) - ) - return ProductsCollection( - products=products, - links=[ - Link( - href=str(request.url_for(f"{self.name}:list-products")), - rel="self", - type=TYPE_JSON, - ) - ], - ) - - async def get_order(self, order_id: str, request: Request) -> Order: - """ - Get details for order with `order_id`. - """ - try: - order = await self.backend.get_order(order_id, request) - except NotFoundException as exc: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="not found") from exc - - order.links.append(Link(href=str(request.url), rel="self", type=TYPE_GEOJSON)) - - return JSONResponse( - jsonable_encoder(order, exclude_unset=True), - status.HTTP_200_OK, - media_type=TYPE_GEOJSON, - ) - - def add_product_router(self, product_router: ProductRouter): - # Give the include a prefix from the product router - self.include_router(product_router, prefix=f"/products/{product_router.product.id}") - self.product_routers[product_router.product.id] = product_router diff --git a/src/stapi_fastapi/products_router.py b/src/stapi_fastapi/products_router.py deleted file mode 100644 index 0d5db46..0000000 --- a/src/stapi_fastapi/products_router.py +++ /dev/null @@ -1,62 +0,0 @@ -# Generic product router factory -from typing import Self -from fastapi import APIRouter, HTTPException, status, Request -from stapi_fastapi.models.opportunity import OpportunityRequest -from stapi_fastapi.models.product import Product -from stapi_fastapi.backend import StapiBackend -from stapi_fastapi.exceptions import ConstraintsException - -""" -/products[MainRouter]/opportunities -/products[MainRouter]/parameters -/products[MainRouter]/order -""" - -class ProductRouter(APIRouter): - - def __init__( - self: Self, - product: Product, - *args, - **kwargs, - ): - super().__init__(*args, **kwargs) - self.product = product - - self.add_api_route( - path="/opportunities", - endpoint=self.search_opportunities, - methods=["POST"], - summary="Search Opportunities for the product" - ) - - self.add_api_route( - path="/parameters", - endpoint=self.get_product_parameters, - methods=["GET"], - summary="Get parameters for the product" - ) - - self.add_api_route( - path="/order", - endpoint=self.create_order, - methods=["POST"], - summary="Create an order for the product" - ) - - async def search_opportunities(self: Self, request: Request, search: OpportunityRequest): - try: - opportunities = await self.product.search_opportunities(search, request) - return opportunities - except ConstraintsException as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail) - - async def get_product_parameters(self: Self, request: Request): - return {"product_id": self.product.id, "parameters": self.product.parameters} - - async def create_order(self: Self, request: Request, payload: OpportunityRequest): - try: - order = await self.product.create_order(payload, request) - return order - except ConstraintsException as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail) From 6e0285d854cadbd22b664a43045f42a51f26af56 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 31 Oct 2024 16:58:23 -0700 Subject: [PATCH 11/27] various cleanup and refinements --- src/stapi_fastapi/__init__.py | 21 ++++++ src/stapi_fastapi/backends/__init__.py | 7 ++ src/stapi_fastapi/backends/product_backend.py | 6 +- src/stapi_fastapi/models/__init__.py | 11 +++ src/stapi_fastapi/models/opportunity.py | 15 ++-- src/stapi_fastapi/models/order.py | 13 +++- src/stapi_fastapi/models/product.py | 21 ++++-- src/stapi_fastapi/models/shared.py | 7 +- src/stapi_fastapi/routers/__init__.py | 7 ++ src/stapi_fastapi/routers/product_router.py | 60 ++++++++-------- src/stapi_fastapi/routers/root_router.py | 40 +++++++---- tests/conftest.py | 70 +++---------------- 12 files changed, 157 insertions(+), 121 deletions(-) diff --git a/src/stapi_fastapi/__init__.py b/src/stapi_fastapi/__init__.py index e69de29..0e2c927 100644 --- a/src/stapi_fastapi/__init__.py +++ b/src/stapi_fastapi/__init__.py @@ -0,0 +1,21 @@ +from .backends import ProductBackend, RootBackend +from .models import ( + Link, + OpportunityPropertiesBase, + Product, + Provider, + ProviderRole, +) +from .routers import ProductRouter, RootRouter + +__all__ = [ + "Link", + "OpportunityPropertiesBase", + "Product", + "ProductBackend", + "ProductRouter", + "Provider", + "ProviderRole", + "RootBackend", + "RootRouter", +] diff --git a/src/stapi_fastapi/backends/__init__.py b/src/stapi_fastapi/backends/__init__.py index e69de29..b4b7ff2 100644 --- a/src/stapi_fastapi/backends/__init__.py +++ b/src/stapi_fastapi/backends/__init__.py @@ -0,0 +1,7 @@ +from .product_backend import ProductBackend +from .root_backend import RootBackend + +__all__ = [ + "ProductBackend", + "RootBackend", +] diff --git a/src/stapi_fastapi/backends/product_backend.py b/src/stapi_fastapi/backends/product_backend.py index 892f488..8ada63b 100644 --- a/src/stapi_fastapi/backends/product_backend.py +++ b/src/stapi_fastapi/backends/product_backend.py @@ -4,15 +4,15 @@ from fastapi import Request -import stapi_fastapi from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest from stapi_fastapi.models.order import Order +from stapi_fastapi.models.product import Product class ProductBackend(Protocol): async def search_opportunities( self, - product: stapi_fastapi.models.product.Product, + product: Product, search: OpportunityRequest, request: Request, ) -> list[Opportunity]: @@ -26,7 +26,7 @@ async def search_opportunities( async def create_order( self, - product: stapi_fastapi.models.product.Product, + product: Product, search: OpportunityRequest, request: Request, ) -> Order: diff --git a/src/stapi_fastapi/models/__init__.py b/src/stapi_fastapi/models/__init__.py index e69de29..fa45b2b 100644 --- a/src/stapi_fastapi/models/__init__.py +++ b/src/stapi_fastapi/models/__init__.py @@ -0,0 +1,11 @@ +from .opportunity import OpportunityPropertiesBase +from .product import Product, Provider, ProviderRole +from .shared import Link + +__all__ = [ + "Link", + "OpportunityPropertiesBase", + "Product", + "Provider", + "ProviderRole", +] diff --git a/src/stapi_fastapi/models/opportunity.py b/src/stapi_fastapi/models/opportunity.py index 60c1faf..f5f5cf4 100644 --- a/src/stapi_fastapi/models/opportunity.py +++ b/src/stapi_fastapi/models/opportunity.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional +from typing import Literal, TypeVar from geojson_pydantic import Feature, FeatureCollection from geojson_pydantic.geometries import Geometry @@ -10,7 +10,7 @@ # Copied and modified from https://github.com/stac-utils/stac-pydantic/blob/main/stac_pydantic/item.py#L11 -class OpportunityProperties(BaseModel): +class OpportunityPropertiesBase(BaseModel): datetime: DatetimeInterval model_config = ConfigDict(extra="allow") @@ -19,15 +19,18 @@ class OpportunityRequest(BaseModel): datetime: DatetimeInterval geometry: Geometry # TODO: validate the CQL2 filter? - filter: Optional[CQL2Filter] = None + filter: CQL2Filter | None = None model_config = ConfigDict(strict=True) -# GENERIC: Each product needs an opportunity model (constraints/parameters) -class Opportunity(Feature[Geometry, OpportunityProperties]): +G = TypeVar("G", bound=Geometry) +P = TypeVar("P", bound=OpportunityPropertiesBase) + + +class Opportunity(Feature[G, P]): type: Literal["Feature"] = "Feature" links: list[Link] = [] -class OpportunityCollection(FeatureCollection[Opportunity]): +class OpportunityCollection(FeatureCollection[Opportunity[G, P]]): type: Literal["FeatureCollection"] = "FeatureCollection" diff --git a/src/stapi_fastapi/models/order.py b/src/stapi_fastapi/models/order.py index 59419b9..5463b45 100644 --- a/src/stapi_fastapi/models/order.py +++ b/src/stapi_fastapi/models/order.py @@ -1,12 +1,19 @@ -from typing import Literal +from typing import Literal, TypeVar from geojson_pydantic import Feature from geojson_pydantic.geometries import Geometry +from pydantic import StrictInt, StrictStr -from stapi_fastapi.models.opportunity import OpportunityProperties +from stapi_fastapi.models.opportunity import OpportunityPropertiesBase from stapi_fastapi.models.shared import Link +G = TypeVar("G", bound=Geometry) +P = TypeVar("P", bound=OpportunityPropertiesBase) -class Order(Feature[Geometry, OpportunityProperties]): + +class Order(Feature[G, P]): + # We need to enforce that orders have an id defined, as that is required to + # retrieve them via the API + id: StrictInt | StrictStr # type: ignore type: Literal["Feature"] = "Feature" links: list[Link] diff --git a/src/stapi_fastapi/models/product.py b/src/stapi_fastapi/models/product.py index e15668a..563c448 100644 --- a/src/stapi_fastapi/models/product.py +++ b/src/stapi_fastapi/models/product.py @@ -2,14 +2,16 @@ from copy import deepcopy from enum import Enum -from typing import Literal, Optional, Self +from typing import TYPE_CHECKING, Literal, Optional, Self from pydantic import AnyHttpUrl, BaseModel, Field -from stapi_fastapi.backends.product_backend import ProductBackend -from stapi_fastapi.models.opportunity import OpportunityProperties +from stapi_fastapi.models.opportunity import OpportunityPropertiesBase from stapi_fastapi.models.shared import Link +if TYPE_CHECKING: + from stapi_fastapi.backends.product_backend import ProductBackend + class ProviderRole(str, Enum): licensor = "licensor" @@ -24,6 +26,11 @@ class Provider(BaseModel): roles: list[ProviderRole] url: AnyHttpUrl + # redefining init is a hack to get str type to validate for `url`, + # as str is ultimately coerced into an AnyHttpUrl automatically anyway + def __init__(self, url: AnyHttpUrl | str, **kwargs): + super().__init__(url=url, **kwargs) + class Product(BaseModel): type_: Literal["Product"] = Field(default="Product", alias="type") @@ -37,14 +44,14 @@ class Product(BaseModel): links: list[Link] # we don't want to include these in the model fields - _constraints: type[OpportunityProperties] + _constraints: type[OpportunityPropertiesBase] _backend: ProductBackend def __init__( - self: Self, + self, *args, backend: ProductBackend, - constraints: type[OpportunityProperties], + constraints: type[OpportunityPropertiesBase], **kwargs, ) -> None: super().__init__(*args, **kwargs) @@ -56,7 +63,7 @@ def backend(self: Self) -> ProductBackend: return self._backend @property - def constraints(self: Self) -> type[OpportunityProperties]: + def constraints(self: Self) -> type[OpportunityPropertiesBase]: return self._constraints def with_links(self: Self, links: list[Link] | None = None) -> Self: diff --git a/src/stapi_fastapi/models/shared.py b/src/stapi_fastapi/models/shared.py index c013658..89bc468 100644 --- a/src/stapi_fastapi/models/shared.py +++ b/src/stapi_fastapi/models/shared.py @@ -14,7 +14,12 @@ class Link(BaseModel): model_config = ConfigDict(extra="allow") - def model_dump_json(self: Self, *args, **kwargs) -> bytes: + # redefining init is a hack to get str type to validate for `href`, + # as str is ultimately coerced into an AnyUrl automatically anyway + def __init__(self, href: AnyUrl | str, **kwargs): + super().__init__(href=href, **kwargs) + + def model_dump_json(self: Self, *args, **kwargs) -> str: # TODO: this isn't working as expected and we get nulls in the output # maybe need to override python dump too # forcing the call to model_dump_json to exclude unset fields by default diff --git a/src/stapi_fastapi/routers/__init__.py b/src/stapi_fastapi/routers/__init__.py index e69de29..3919e6b 100644 --- a/src/stapi_fastapi/routers/__init__.py +++ b/src/stapi_fastapi/routers/__init__.py @@ -0,0 +1,7 @@ +from .product_router import ProductRouter +from .root_router import RootRouter + +__all__ = [ + "ProductRouter", + "RootRouter", +] diff --git a/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py index 8621a90..3aab967 100644 --- a/src/stapi_fastapi/routers/product_router.py +++ b/src/stapi_fastapi/routers/product_router.py @@ -1,38 +1,32 @@ -# Generic product router factory from __future__ import annotations -from typing import Self +from typing import TYPE_CHECKING, Self from fastapi import APIRouter, HTTPException, Request, status -from fastapi.encoders import jsonable_encoder -from fastapi.responses import JSONResponse -import stapi_fastapi from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON from stapi_fastapi.exceptions import ConstraintsException from stapi_fastapi.models.opportunity import ( OpportunityCollection, OpportunityRequest, ) +from stapi_fastapi.models.order import Order from stapi_fastapi.models.product import Product from stapi_fastapi.models.shared import Link from stapi_fastapi.types.json_schema_model import JsonSchemaModel -""" -/products[MainRouter]/opportunities -/products[MainRouter]/parameters -/products[MainRouter]/order -""" +if TYPE_CHECKING: + from stapi_fastapi.routers import RootRouter class ProductRouter(APIRouter): def __init__( - self: Self, + self, product: Product, - root_router: stapi_fastapi.routers.RootRouter, + root_router: RootRouter, *args, **kwargs, - ): + ) -> None: super().__init__(*args, **kwargs) self.product = product self.root_router = root_router @@ -50,6 +44,13 @@ def __init__( endpoint=self.search_opportunities, name=f"{self.root_router.name}:{self.product.id}:search-opportunities", methods=["POST"], + responses={ + 200: { + "content": { + "TYPE_GEOJSON": {}, + }, + } + }, summary="Search Opportunities for the product", ) @@ -66,6 +67,13 @@ def __init__( endpoint=self.create_order, name=f"{self.root_router.name}:{self.product.id}:create-order", methods=["POST"], + responses={ + 201: { + "content": { + "TYPE_GEOJSON": {}, + }, + } + }, summary="Create an order for the product", ) @@ -92,16 +100,13 @@ async def search_opportunities( """ try: opportunities = await self.product.backend.search_opportunities( - search, request + self.product, search, request ) except ConstraintsException as exc: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail) - return JSONResponse( - jsonable_encoder(OpportunityCollection(features=opportunities)), - media_type=TYPE_GEOJSON, - ) + return OpportunityCollection(features=opportunities) - async def get_product_constraints(self: Self, request: Request) -> JsonSchemaModel: + async def get_product_constraints(self: Self) -> JsonSchemaModel: """ Return supported constraints of a specific product """ @@ -109,20 +114,19 @@ async def get_product_constraints(self: Self, request: Request) -> JsonSchemaMod async def create_order( self, payload: OpportunityRequest, request: Request - ) -> JSONResponse: + ) -> Order: """ Create a new order. """ try: - order = await self.product.backend.create_order(payload, request) + order = await self.product.backend.create_order( + self.product, + payload, + request, + ) except ConstraintsException as exc: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail) location = self.root_router.generate_order_href(request, order.id) - order.links.append(Link(href=location, rel="self", type=TYPE_GEOJSON)) - return JSONResponse( - jsonable_encoder(order, exclude_unset=True), - status.HTTP_201_CREATED, - {"Location": location}, - TYPE_GEOJSON, - ) + order.links.append(Link(href=str(location), rel="self", type=TYPE_GEOJSON)) + return order diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index da8fe6d..3f64a85 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -1,8 +1,7 @@ from typing import Self from fastapi import APIRouter, HTTPException, Request, status -from fastapi.encoders import jsonable_encoder -from fastapi.responses import JSONResponse +from fastapi.datastructures import URL from stapi_fastapi.backends.root_backend import RootBackend from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON @@ -16,20 +15,24 @@ class RootRouter(APIRouter): def __init__( - self: Self, + self, backend: RootBackend, name: str = "root", openapi_endpoint_name="openapi", docs_endpoint_name="swagger_ui_html", *args, **kwargs, - ): + ) -> None: super().__init__(*args, **kwargs) self.backend = backend self.name = name self.openapi_endpoint_name = openapi_endpoint_name self.docs_endpoint_name = docs_endpoint_name + # A dict is used to track the product routers so we can ensure + # idempotentcy in case a product is added multiple times, and also to + # manage clobbering if multiple products with the same product_id are + # added. self.product_routers: dict[str, ProductRouter] = {} self.add_api_route( @@ -53,6 +56,13 @@ def __init__( self.get_orders, methods=["GET"], name=f"{self.name}:list-orders", + responses={ + 200: { + "content": { + "TYPE_GEOJSON": {}, + }, + } + }, tags=["Order"], ) @@ -61,6 +71,13 @@ def __init__( self.get_order, methods=["GET"], name=f"{self.name}:get-order", + responses={ + 200: { + "content": { + "TYPE_GEOJSON": {}, + }, + } + }, tags=["Orders"], ) @@ -108,7 +125,7 @@ def get_products(self, request: Request) -> ProductsCollection: ) async def get_orders(self, request: Request) -> list[Order]: - orders = await self.backend.orders(request) + orders = await self.backend.get_orders(request) for order in orders: order.links.append( Link( @@ -119,7 +136,7 @@ async def get_orders(self, request: Request) -> list[Order]: type=TYPE_JSON, ) ) - return list[Order] + return orders async def get_order(self: Self, order_id: str, request: Request) -> Order: """ @@ -131,12 +148,7 @@ async def get_order(self: Self, order_id: str, request: Request) -> Order: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="not found") from exc order.links.append(Link(href=str(request.url), rel="self", type=TYPE_GEOJSON)) - - return JSONResponse( - jsonable_encoder(order, exclude_unset=True), - status.HTTP_200_OK, - media_type=TYPE_GEOJSON, - ) + return order def add_product(self: Self, product: Product) -> None: # Give the include a prefix from the product router @@ -144,5 +156,5 @@ def add_product(self: Self, product: Product) -> None: self.include_router(product_router, prefix=f"/products/{product.id}") self.product_routers[product.id] = product_router - def generate_order_href(self: Self, request: Request, order_id: str) -> str: - return str(request.url_for(f"{self.name}:get-order", order_id=order_id)) + def generate_order_href(self: Self, request: Request, order_id: int | str) -> URL: + return request.url_for(f"{self.name}:get-order", order_id=order_id) diff --git a/tests/conftest.py b/tests/conftest.py index 6581c77..0986778 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,6 @@ from collections.abc import Iterator from datetime import UTC, datetime, timedelta, timezone -from typing import Callable, Generator, List, TypeVar +from typing import Callable, List from urllib.parse import urljoin from uuid import uuid4 @@ -8,41 +8,24 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from geojson_pydantic import Point -from pytest import fixture +from geojson_pydantic.types import Position2D from stapi_fastapi.models.opportunity import ( Opportunity, - OpportunityProperties, - OpportunityRequest, + OpportunityPropertiesBase, ) from stapi_fastapi.models.product import Product, Provider, ProviderRole - -# from stapi_fastapi.main_router import MainRouter from stapi_fastapi.routers.root_router import RootRouter from .backend import TestProductBackend, TestRootBackend -# Define concrete classes for Products to mock -class TestSpotlight(Product): - def search_opportunities(self, product, search, request): - return [] - - def create_order(self, product, search, request): - return [] - - -class TestSpotlightProperties(OpportunityProperties): +class TestSpotlightProperties(OpportunityPropertiesBase): off_nadir: int @pytest.fixture def mock_product_test_spotlight(mock_provider_test: Provider) -> Product: """Fixture for a mock Test Spotlight product.""" - now = datetime.now(timezone.utc) # Use timezone-aware datetime - start = now - end = start + timedelta(days=5) - datetime_interval = f"{start.isoformat()}/{end.isoformat()}" - return Product( id="test-spotlight", title="Test Spotlight Product", @@ -52,7 +35,7 @@ def mock_product_test_spotlight(mock_provider_test: Provider) -> Product: providers=[mock_provider_test], links=[], constraints=TestSpotlightProperties, - backend=TestProductBackend, + backend=TestProductBackend(), ) @@ -95,7 +78,7 @@ def url_for(value: str) -> str: @pytest.fixture -def products(mock_product_test_spotlight) -> Iterator[Product]: +def products(mock_product_test_spotlight) -> list[Product]: return [mock_product_test_spotlight] @@ -113,39 +96,6 @@ def opportunities(products: list[Product]) -> Iterator[list[Opportunity]]: ] -@pytest.fixture -def allowed_payloads(products: list[Product]) -> Iterator[list[OpportunityRequest]]: - yield [ - OpportunityRequest( - geometry=Point(type="Point", coordinates=[13.4, 52.5]), - product_id=products[0].id, - datetime=(datetime.now(UTC), datetime.now(UTC)), - filter={}, - ), - ] - - -T = TypeVar("T") - -YieldFixture = Generator[T, None, None] - - -@fixture(scope="session") -def base_url() -> YieldFixture[str]: - yield "http://stapiserver" - - -@fixture(scope="session") -def url_for(base_url: str) -> YieldFixture[Callable[[str], str]]: - def with_trailing_slash(value: str) -> str: - return value if value.endswith("/") else f"{value}/" - - def url_for(value: str) -> str: - return urljoin(with_trailing_slash(base_url), f"./{value.lstrip('/')}") - - yield url_for - - @pytest.fixture def mock_provider_test() -> Provider: return Provider( @@ -162,16 +112,18 @@ def mock_test_spotlight_opportunities() -> List[Opportunity]: now = datetime.now(timezone.utc) # Use timezone-aware datetime start = now end = start + timedelta(days=5) - datetime_interval = f"{start.isoformat()}/{end.isoformat()}" # Create a list of mock opportunities for the given product return [ Opportunity( id=str(uuid4()), type="Feature", - geometry=Point(type="Point", coordinates=[0, 0]), # Simple point geometry + geometry=Point( + type="Point", + coordinates=Position2D(longitude=0.0, latitude=0.0), + ), properties=TestSpotlightProperties( - datetime=datetime_interval, + datetime=(start, end), off_nadir=20, ), ), From c9e3aa3b9311e51206180c1879797564838e42ef Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 31 Oct 2024 17:01:48 -0700 Subject: [PATCH 12/27] no longer a problem with the root router / route --- src/stapi_fastapi/routers/root_router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index 3f64a85..b624cee 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -36,7 +36,7 @@ def __init__( self.product_routers: dict[str, ProductRouter] = {} self.add_api_route( - "$", + "/", self.get_root, methods=["GET"], name=f"{self.name}:root", From 6a2f4d743dfc15cbd8e63bb2cb1dc3f76a617b4d Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 31 Oct 2024 17:04:08 -0700 Subject: [PATCH 13/27] rename tests/backend{,s}.py --- tests/{backend.py => backends.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{backend.py => backends.py} (100%) diff --git a/tests/backend.py b/tests/backends.py similarity index 100% rename from tests/backend.py rename to tests/backends.py From fba52ed8f87224cb10fc26ebdac94cfd4273e5b0 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Thu, 31 Oct 2024 18:31:48 -0700 Subject: [PATCH 14/27] get tests passing --- src/stapi_fastapi/responses.py | 9 +++ src/stapi_fastapi/routers/product_router.py | 28 +++---- src/stapi_fastapi/routers/root_router.py | 18 +---- tests/backends.py | 19 +++-- tests/conftest.py | 68 ++++++++++------ tests/opportunity_test.py | 25 +++--- tests/order_test.py | 88 ++++++++++++--------- tests/utils.py | 7 +- 8 files changed, 147 insertions(+), 115 deletions(-) create mode 100644 src/stapi_fastapi/responses.py diff --git a/src/stapi_fastapi/responses.py b/src/stapi_fastapi/responses.py new file mode 100644 index 0000000..e8f6e1a --- /dev/null +++ b/src/stapi_fastapi/responses.py @@ -0,0 +1,9 @@ +from fastapi.responses import JSONResponse + +from stapi_fastapi.constants import TYPE_GEOJSON + + +class GeoJSONResponse(JSONResponse): + def __init__(self, *args, **kwargs) -> None: + kwargs["media_type"] = TYPE_GEOJSON + super().__init__(*args, **kwargs) diff --git a/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py index 3aab967..ac5deb1 100644 --- a/src/stapi_fastapi/routers/product_router.py +++ b/src/stapi_fastapi/routers/product_router.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Self -from fastapi import APIRouter, HTTPException, Request, status +from fastapi import APIRouter, HTTPException, Request, Response, status from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON from stapi_fastapi.exceptions import ConstraintsException @@ -13,6 +13,7 @@ from stapi_fastapi.models.order import Order from stapi_fastapi.models.product import Product from stapi_fastapi.models.shared import Link +from stapi_fastapi.responses import GeoJSONResponse from stapi_fastapi.types.json_schema_model import JsonSchemaModel if TYPE_CHECKING: @@ -44,13 +45,7 @@ def __init__( endpoint=self.search_opportunities, name=f"{self.root_router.name}:{self.product.id}:search-opportunities", methods=["POST"], - responses={ - 200: { - "content": { - "TYPE_GEOJSON": {}, - }, - } - }, + response_class=GeoJSONResponse, summary="Search Opportunities for the product", ) @@ -67,13 +62,8 @@ def __init__( endpoint=self.create_order, name=f"{self.root_router.name}:{self.product.id}:create-order", methods=["POST"], - responses={ - 201: { - "content": { - "TYPE_GEOJSON": {}, - }, - } - }, + response_class=GeoJSONResponse, + status_code=status.HTTP_201_CREATED, summary="Create an order for the product", ) @@ -104,6 +94,7 @@ async def search_opportunities( ) except ConstraintsException as exc: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail) + return OpportunityCollection(features=opportunities) async def get_product_constraints(self: Self) -> JsonSchemaModel: @@ -113,7 +104,7 @@ async def get_product_constraints(self: Self) -> JsonSchemaModel: return self.product.constraints async def create_order( - self, payload: OpportunityRequest, request: Request + self, payload: OpportunityRequest, request: Request, response: Response ) -> Order: """ Create a new order. @@ -127,6 +118,7 @@ async def create_order( except ConstraintsException as exc: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail) - location = self.root_router.generate_order_href(request, order.id) - order.links.append(Link(href=str(location), rel="self", type=TYPE_GEOJSON)) + location = str(self.root_router.generate_order_href(request, order.id)) + order.links.append(Link(href=location, rel="self", type=TYPE_GEOJSON)) + response.headers["Location"] = location return order diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index b624cee..0b98836 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -10,6 +10,7 @@ from stapi_fastapi.models.product import Product, ProductsCollection from stapi_fastapi.models.root import RootResponse from stapi_fastapi.models.shared import Link +from stapi_fastapi.responses import GeoJSONResponse from stapi_fastapi.routers.product_router import ProductRouter @@ -56,13 +57,7 @@ def __init__( self.get_orders, methods=["GET"], name=f"{self.name}:list-orders", - responses={ - 200: { - "content": { - "TYPE_GEOJSON": {}, - }, - } - }, + response_class=GeoJSONResponse, tags=["Order"], ) @@ -71,13 +66,7 @@ def __init__( self.get_order, methods=["GET"], name=f"{self.name}:get-order", - responses={ - 200: { - "content": { - "TYPE_GEOJSON": {}, - }, - } - }, + response_class=GeoJSONResponse, tags=["Orders"], ) @@ -136,6 +125,7 @@ async def get_orders(self, request: Request) -> list[Order]: type=TYPE_JSON, ) ) + return orders async def get_order(self: Self, order_id: str, request: Request) -> Order: diff --git a/tests/backends.py b/tests/backends.py index 58067e9..6619b40 100644 --- a/tests/backends.py +++ b/tests/backends.py @@ -1,4 +1,3 @@ -from typing import Mapping from uuid import uuid4 from fastapi import Request @@ -9,8 +8,13 @@ from stapi_fastapi.models.product import Product -class TestRootBackend: - _orders: Mapping[str, Order] = {} +class MockOrderDB(dict[int | str, Order]): + pass + + +class MockRootBackend: + def __init__(self, orders: MockOrderDB) -> None: + self._orders = orders async def get_orders(self, request: Request) -> list[Order]: """ @@ -28,10 +32,11 @@ async def get_order(self, order_id: str, request: Request) -> Order: raise NotFoundException() -class TestProductBackend(ProductBackend): - _opportunities: list[Opportunity] = [] - _allowed_payloads: list[OpportunityRequest] = [] - _orders: Mapping[str, Order] = {} +class MockProductBackend(ProductBackend): + def __init__(self, orders: MockOrderDB) -> None: + self._opportunities: list[Opportunity] = [] + self._allowed_payloads: list[OpportunityRequest] = [] + self._orders = orders async def search_opportunities( self, product: Product, search: OpportunityRequest, request: Request diff --git a/tests/conftest.py b/tests/conftest.py index 0986778..c0a3aea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,19 +12,42 @@ from stapi_fastapi.models.opportunity import ( Opportunity, OpportunityPropertiesBase, + OpportunityRequest, ) from stapi_fastapi.models.product import Product, Provider, ProviderRole from stapi_fastapi.routers.root_router import RootRouter -from .backend import TestProductBackend, TestRootBackend +from .backends import MockOrderDB, MockProductBackend, MockRootBackend class TestSpotlightProperties(OpportunityPropertiesBase): off_nadir: int +@pytest.fixture(scope="session") +def base_url() -> Iterator[str]: + yield "http://stapiserver" + + @pytest.fixture -def mock_product_test_spotlight(mock_provider_test: Provider) -> Product: +def order_db() -> MockOrderDB: + return MockOrderDB() + + +@pytest.fixture +def product_backend(order_db: MockOrderDB) -> MockProductBackend: + return MockProductBackend(order_db) + + +@pytest.fixture +def root_backend(order_db) -> MockRootBackend: + return MockRootBackend(order_db) + + +@pytest.fixture +def mock_product_test_spotlight( + product_backend: MockProductBackend, mock_provider: Provider +) -> Product: """Fixture for a mock Test Spotlight product.""" return Product( id="test-spotlight", @@ -32,28 +55,13 @@ def mock_product_test_spotlight(mock_provider_test: Provider) -> Product: description="Test product for test spotlight", license="CC-BY-4.0", keywords=["test", "satellite"], - providers=[mock_provider_test], + providers=[mock_provider], links=[], constraints=TestSpotlightProperties, - backend=TestProductBackend(), + backend=product_backend, ) -@pytest.fixture(scope="session") -def base_url() -> Iterator[str]: - yield "http://stapiserver" - - -@pytest.fixture -def product_backend() -> Iterator[TestProductBackend]: - yield TestProductBackend() - - -@pytest.fixture -def root_backend() -> Iterator[TestRootBackend]: - yield TestRootBackend() - - @pytest.fixture def stapi_client( root_backend, mock_product_test_spotlight, base_url: str @@ -63,7 +71,8 @@ def stapi_client( app = FastAPI() app.include_router(root_router, prefix="") - yield TestClient(app, base_url=f"{base_url}") + with TestClient(app, base_url=f"{base_url}") as client: + yield client @pytest.fixture(scope="session") @@ -83,8 +92,8 @@ def products(mock_product_test_spotlight) -> list[Product]: @pytest.fixture -def opportunities(products: list[Product]) -> Iterator[list[Opportunity]]: - yield [ +def opportunities(products: list[Product]) -> list[Opportunity]: + return [ Opportunity( geometry=Point(type="Point", coordinates=[13.4, 52.5]), properties={ @@ -97,7 +106,7 @@ def opportunities(products: list[Product]) -> Iterator[list[Opportunity]]: @pytest.fixture -def mock_provider_test() -> Provider: +def mock_provider() -> Provider: return Provider( name="Test Provider", description="A provider for Test data", @@ -128,3 +137,16 @@ def mock_test_spotlight_opportunities() -> List[Opportunity]: ), ), ] + + +@pytest.fixture +def allowed_payloads() -> list[OpportunityRequest]: + return [ + OpportunityRequest( + geometry=Point( + type="Point", coordinates=Position2D(longitude=13.4, latitude=52.5) + ), + datetime=(datetime.now(UTC), datetime.now(UTC)), + filter={}, + ), + ] diff --git a/tests/opportunity_test.py b/tests/opportunity_test.py index ce753d3..2e67d7f 100644 --- a/tests/opportunity_test.py +++ b/tests/opportunity_test.py @@ -1,12 +1,11 @@ -import json from datetime import UTC, datetime, timedelta from typing import List import pytest from fastapi.testclient import TestClient -from stapi_fastapi.models.opportunity import Opportunity +from stapi_fastapi.models.opportunity import Opportunity, OpportunityCollection -from .backend import TestProductBackend +from .backends import MockProductBackend from .datetime_interval_test import rfc3339_strftime @@ -14,9 +13,9 @@ def test_search_opportunities_response( product_id: str, mock_test_spotlight_opportunities: List[Opportunity], - product_backend: TestProductBackend, + product_backend: MockProductBackend, stapi_client: TestClient, -): +) -> None: product_backend._opportunities = mock_test_spotlight_opportunities now = datetime.now(UTC) @@ -48,15 +47,11 @@ def test_search_opportunities_response( # Use POST method to send the payload response = stapi_client.post(url, json=request_payload) - print(json.dumps(response.json(), indent=4)) - # Validate response status and structure assert response.status_code == 200, f"Failed for product: {product_id}" - assert isinstance( - response.json(), list - ), "Response should be a list of opportunities" - for opportunity in response.json(): - assert "id" in opportunity, "Opportunity item should have an 'id' field" - assert ( - "properties" in opportunity - ), "Opportunity item should have a 'properties' field" + _json = response.json() + + try: + OpportunityCollection(**_json) + except Exception as _: + pytest.fail("response is not an opportunity collection") diff --git a/tests/order_test.py b/tests/order_test.py index cb9b521..56849d6 100644 --- a/tests/order_test.py +++ b/tests/order_test.py @@ -1,55 +1,69 @@ from datetime import UTC, datetime, timedelta +import pytest +from fastapi import status +from fastapi.testclient import TestClient +from httpx import Response +from stapi_fastapi.models.opportunity import OpportunityRequest + +from .backends import MockProductBackend +from .utils import find_link + NOW = datetime.now(UTC) START = NOW END = START + timedelta(days=5) -# @fixture -# def new_order_response( -# stapi_backend: TestRootBackend, -# stapi_client: TestClient, -# allowed_payloads: list[OpportunityRequest], -# ) -> Generator[Response, None, None]: -# stapi_backend._allowed_payloads = allowed_payloads +@pytest.fixture +def new_order_response( + product_id: str, + product_backend: MockProductBackend, + stapi_client: TestClient, + allowed_payloads: list[OpportunityRequest], +) -> Response: + product_backend._allowed_payloads = allowed_payloads -# res = stapi_client.post( -# "/orders", -# json=allowed_payloads[0].model_dump(), -# ) + res = stapi_client.post( + f"products/{product_id}/order", + json=allowed_payloads[0].model_dump(), + ) -# assert res.status_code == status.HTTP_201_CREATED -# assert res.headers["Content-Type"] == "application/geo+json" -# yield res + assert res.status_code == status.HTTP_201_CREATED + assert res.headers["Content-Type"] == "application/geo+json" + return res -# def test_new_order_location_header_matches_self_link(new_order_response: Response): -# order = new_order_response.json() -# assert new_order_response.headers["Location"] == str( -# find_link(order["links"], "self")["href"] -# ) +@pytest.mark.parametrize("product_id", ["test-spotlight"]) +def test_new_order_location_header_matches_self_link( + new_order_response: Response, +) -> None: + order = new_order_response.json() + link = find_link(order["links"], "self") + assert link + assert new_order_response.headers["Location"] == str(link["href"]) -# @fixture -# def get_order_response( -# stapi_client: TestClient, new_order_response: Response -# ) -> Generator[Response, None, None]: -# order_id = new_order_response.json()["id"] +@pytest.fixture +def get_order_response( + stapi_client: TestClient, new_order_response: Response +) -> Response: + order_id = new_order_response.json()["id"] -# res = stapi_client.get(f"/orders/{order_id}") -# assert res.status_code == status.HTTP_200_OK -# assert res.headers["Content-Type"] == "application/geo+json" -# yield res + res = stapi_client.get(f"/orders/{order_id}") + assert res.status_code == status.HTTP_200_OK + assert res.headers["Content-Type"] == "application/geo+json" + return res -# def test_get_order_properties(get_order_response: Response, allowed_payloads): -# order = get_order_response.json() +@pytest.mark.parametrize("product_id", ["test-spotlight"]) +def test_get_order_properties(get_order_response: Response, allowed_payloads) -> None: + order = get_order_response.json() -# assert order["geometry"] == { -# "type": "Point", -# "coordinates": list(allowed_payloads[0].geometry.coordinates), -# } + assert order["geometry"] == { + "type": "Point", + "coordinates": list(allowed_payloads[0].geometry.coordinates), + } -# assert ( -# order["properties"]["datetime"] == allowed_payloads[0].model_dump()["datetime"] -# ) + assert ( + order["properties"]["datetime"] == allowed_payloads[0].model_dump()["datetime"] + ) diff --git a/tests/utils.py b/tests/utils.py index fdb3342..20947d4 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,2 +1,7 @@ -def find_link(links: list[dict], rel: str) -> dict | None: +from typing import Any + +link_dict = dict[str, Any] + + +def find_link(links: list[link_dict], rel: str) -> link_dict | None: return next((link for link in links if link["rel"] == rel), None) From 1bde9f54244efb9079f211d53e4d3f9713c16aaa Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Fri, 1 Nov 2024 12:54:32 -0700 Subject: [PATCH 15/27] more cleanup --- tests/conftest.py | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c0a3aea..1f2495d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,6 @@ from collections.abc import Iterator from datetime import UTC, datetime, timedelta, timezone -from typing import Callable, List +from typing import Callable from urllib.parse import urljoin from uuid import uuid4 @@ -40,7 +40,7 @@ def product_backend(order_db: MockOrderDB) -> MockProductBackend: @pytest.fixture -def root_backend(order_db) -> MockRootBackend: +def root_backend(order_db: MockOrderDB) -> MockRootBackend: return MockRootBackend(order_db) @@ -87,24 +87,10 @@ def url_for(value: str) -> str: @pytest.fixture -def products(mock_product_test_spotlight) -> list[Product]: +def products(mock_product_test_spotlight: Product) -> list[Product]: return [mock_product_test_spotlight] -@pytest.fixture -def opportunities(products: list[Product]) -> list[Opportunity]: - return [ - Opportunity( - geometry=Point(type="Point", coordinates=[13.4, 52.5]), - properties={ - "product_id": products[0].id, - "datetime": (datetime.now(UTC), datetime.now(UTC)), - "filter": {}, - }, - ) - ] - - @pytest.fixture def mock_provider() -> Provider: return Provider( @@ -116,7 +102,7 @@ def mock_provider() -> Provider: @pytest.fixture -def mock_test_spotlight_opportunities() -> List[Opportunity]: +def mock_test_spotlight_opportunities() -> list[Opportunity]: """Fixture to create mock data for Opportunities for `test-spotlight-1`.""" now = datetime.now(timezone.utc) # Use timezone-aware datetime start = now From 375c51c10785f094c62e6b7cc8bf89da9bfbb08a Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Fri, 1 Nov 2024 16:17:38 -0400 Subject: [PATCH 16/27] fixes StapiExceptions Co-authored-by: Tyler Co-authored-by: Jarrett Keifer --- src/stapi_fastapi/backends/product_backend.py | 4 ++-- src/stapi_fastapi/backends/root_backend.py | 2 +- src/stapi_fastapi/exceptions.py | 21 ++++++++----------- src/stapi_fastapi/models/shared.py | 8 +++---- src/stapi_fastapi/routers/root_router.py | 9 ++------ 5 files changed, 18 insertions(+), 26 deletions(-) diff --git a/src/stapi_fastapi/backends/product_backend.py b/src/stapi_fastapi/backends/product_backend.py index 8ada63b..4cccae6 100644 --- a/src/stapi_fastapi/backends/product_backend.py +++ b/src/stapi_fastapi/backends/product_backend.py @@ -20,7 +20,7 @@ async def search_opportunities( Search for ordering opportunities for the given search parameters. Backends must validate search constraints and raise - `stapi_fastapi.backend.exceptions.ConstraintsException` if not valid. + `stapi_fastapi.exceptions.ConstraintsException` if not valid. """ ... @@ -34,6 +34,6 @@ async def create_order( Create a new order. Backends must validate order payload and raise - `stapi_fastapi.backend.exceptions.ConstraintsException` if not valid. + `stapi_fastapi.exceptions.ConstraintsException` if not valid. """ ... diff --git a/src/stapi_fastapi/backends/root_backend.py b/src/stapi_fastapi/backends/root_backend.py index 79887bf..87fdafb 100644 --- a/src/stapi_fastapi/backends/root_backend.py +++ b/src/stapi_fastapi/backends/root_backend.py @@ -16,7 +16,7 @@ async def get_order(self, order_id: str, request: Request) -> Order: """ Get details for order with `order_id`. - Backends must raise `stapi_fastapi.backend.exceptions.NotFoundException` + Backends must raise `stapi_fastapi.exceptions.NotFoundException` if not found or access denied. """ ... diff --git a/src/stapi_fastapi/exceptions.py b/src/stapi_fastapi/exceptions.py index 278143d..510d248 100644 --- a/src/stapi_fastapi/exceptions.py +++ b/src/stapi_fastapi/exceptions.py @@ -1,20 +1,17 @@ -from typing import Any, Mapping +from typing import Any -from fastapi import HTTPException +from fastapi import HTTPException, status class StapiException(HTTPException): - def __init__(self, status_code: int, detail: str) -> None: - super().__init__(status_code, detail) - + pass -class ConstraintsException(Exception): - detail: Mapping[str, Any] | None - def __init__(self, detail: Mapping[str, Any] | None = None) -> None: - super().__init__() - self.detail = detail +class ConstraintsException(StapiException): + def __init__(self, detail: Any) -> None: + super().__init__(status.HTTP_422_UNPROCESSABLE_ENTITY, detail) -class NotFoundException(Exception): - pass +class NotFoundException(StapiException): + def __init__(self, detail: Any) -> None: + super().__init__(status.HTTP_404_NOT_FOUND, detail) diff --git a/src/stapi_fastapi/models/shared.py b/src/stapi_fastapi/models/shared.py index 89bc468..63a621d 100644 --- a/src/stapi_fastapi/models/shared.py +++ b/src/stapi_fastapi/models/shared.py @@ -1,4 +1,4 @@ -from typing import Any, Optional, Self, Union +from typing import Any, Self from pydantic import AnyUrl, BaseModel, ConfigDict @@ -7,9 +7,9 @@ class Link(BaseModel): href: AnyUrl rel: str type: str | None = None - title: Optional[str] = None - method: Optional[str] = None - headers: Optional[dict[str, Union[str, list[str]]]] = None + title: str | None = None + method: str | None = None + headers: dict[str, str | list[str]] | None = None body: Any = None model_config = ConfigDict(extra="allow") diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index 0b98836..8ccaa71 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -1,11 +1,10 @@ from typing import Self -from fastapi import APIRouter, HTTPException, Request, status +from fastapi import APIRouter, Request from fastapi.datastructures import URL from stapi_fastapi.backends.root_backend import RootBackend from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON -from stapi_fastapi.exceptions import NotFoundException from stapi_fastapi.models.order import Order from stapi_fastapi.models.product import Product, ProductsCollection from stapi_fastapi.models.root import RootResponse @@ -132,11 +131,7 @@ async def get_order(self: Self, order_id: str, request: Request) -> Order: """ Get details for order with `order_id`. """ - try: - order = await self.backend.get_order(order_id, request) - except NotFoundException as exc: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="not found") from exc - + order = await self.backend.get_order(order_id, request) order.links.append(Link(href=str(request.url), rel="self", type=TYPE_GEOJSON)) return order From 07b259764a6badb2c8ec988dd7fc43accb3dae10 Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Fri, 1 Nov 2024 16:18:55 -0400 Subject: [PATCH 17/27] removes dupe HTTPException in models --- src/stapi_fastapi/models/shared.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/stapi_fastapi/models/shared.py b/src/stapi_fastapi/models/shared.py index 63a621d..cfabd84 100644 --- a/src/stapi_fastapi/models/shared.py +++ b/src/stapi_fastapi/models/shared.py @@ -25,7 +25,3 @@ def model_dump_json(self: Self, *args, **kwargs) -> str: # forcing the call to model_dump_json to exclude unset fields by default kwargs["exclude_unset"] = kwargs.get("exclude_unset", True) return super().model_dump_json(*args, **kwargs) - - -class HTTPException(BaseModel): - detail: str From 9355fdf092d4c2d9e0bcae398be59c2069ef40cd Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Fri, 1 Nov 2024 16:40:03 -0400 Subject: [PATCH 18/27] passes product router instead of product to backend Co-authored-by: Tyler Co-authored-by: Jarrett Keifer --- src/stapi_fastapi/backends/product_backend.py | 6 +++--- src/stapi_fastapi/routers/product_router.py | 4 ++-- tests/backends.py | 14 ++++++++++---- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/stapi_fastapi/backends/product_backend.py b/src/stapi_fastapi/backends/product_backend.py index 4cccae6..e2e0ce0 100644 --- a/src/stapi_fastapi/backends/product_backend.py +++ b/src/stapi_fastapi/backends/product_backend.py @@ -6,13 +6,13 @@ from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest from stapi_fastapi.models.order import Order -from stapi_fastapi.models.product import Product +from stapi_fastapi.routers.product_router import ProductRouter class ProductBackend(Protocol): async def search_opportunities( self, - product: Product, + product_router: ProductRouter, search: OpportunityRequest, request: Request, ) -> list[Opportunity]: @@ -26,7 +26,7 @@ async def search_opportunities( async def create_order( self, - product: Product, + product_router: ProductRouter, search: OpportunityRequest, request: Request, ) -> Order: diff --git a/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py index ac5deb1..36174cf 100644 --- a/src/stapi_fastapi/routers/product_router.py +++ b/src/stapi_fastapi/routers/product_router.py @@ -90,7 +90,7 @@ async def search_opportunities( """ try: opportunities = await self.product.backend.search_opportunities( - self.product, search, request + self, search, request ) except ConstraintsException as exc: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail) @@ -111,7 +111,7 @@ async def create_order( """ try: order = await self.product.backend.create_order( - self.product, + self, payload, request, ) diff --git a/tests/backends.py b/tests/backends.py index 6619b40..a269c29 100644 --- a/tests/backends.py +++ b/tests/backends.py @@ -5,7 +5,7 @@ from stapi_fastapi.exceptions import ConstraintsException, NotFoundException from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest from stapi_fastapi.models.order import Order -from stapi_fastapi.models.product import Product +from stapi_fastapi.routers.product_router import ProductRouter class MockOrderDB(dict[int | str, Order]): @@ -39,12 +39,18 @@ def __init__(self, orders: MockOrderDB) -> None: self._orders = orders async def search_opportunities( - self, product: Product, search: OpportunityRequest, request: Request + self, + product_router: ProductRouter, + search: OpportunityRequest, + request: Request, ) -> list[Opportunity]: return [o.model_copy(update=search.model_dump()) for o in self._opportunities] async def create_order( - self, product: Product, payload: OpportunityRequest, request: Request + self, + product_router: ProductRouter, + payload: OpportunityRequest, + request: Request, ) -> Order: """ Create a new order. @@ -57,7 +63,7 @@ async def create_order( properties={ "filter": payload.filter, "datetime": payload.datetime, - "product_id": product.id, + "product_id": product_router.product.id, }, links=[], ) From f965f9976ad8882d895f0fdaa138966daaee6ac9 Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Fri, 1 Nov 2024 17:07:31 -0400 Subject: [PATCH 19/27] Adds OrderCollection type and returns for get_orders Co-authored-by: Tyler Co-authored-by: Jarrett Keifer --- src/stapi_fastapi/backends/root_backend.py | 4 ++-- src/stapi_fastapi/models/order.py | 20 +++++++++++++------- src/stapi_fastapi/routers/root_router.py | 4 ++-- tests/backends.py | 4 ++-- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/stapi_fastapi/backends/root_backend.py b/src/stapi_fastapi/backends/root_backend.py index 87fdafb..a4ee60c 100644 --- a/src/stapi_fastapi/backends/root_backend.py +++ b/src/stapi_fastapi/backends/root_backend.py @@ -2,11 +2,11 @@ from fastapi import Request -from stapi_fastapi.models.order import Order +from stapi_fastapi.models.order import Order, OrderCollection class RootBackend(Protocol): - async def get_orders(self, request: Request) -> list[Order]: + async def get_orders(self, request: Request) -> OrderCollection: """ Return a list of existing orders. """ diff --git a/src/stapi_fastapi/models/order.py b/src/stapi_fastapi/models/order.py index 5463b45..476d82d 100644 --- a/src/stapi_fastapi/models/order.py +++ b/src/stapi_fastapi/models/order.py @@ -1,19 +1,25 @@ -from typing import Literal, TypeVar +from typing import Literal -from geojson_pydantic import Feature +from geojson_pydantic import Feature, FeatureCollection from geojson_pydantic.geometries import Geometry -from pydantic import StrictInt, StrictStr +from pydantic import BaseModel, ConfigDict, StrictInt, StrictStr -from stapi_fastapi.models.opportunity import OpportunityPropertiesBase from stapi_fastapi.models.shared import Link +from stapi_fastapi.types.datetime_interval import DatetimeInterval -G = TypeVar("G", bound=Geometry) -P = TypeVar("P", bound=OpportunityPropertiesBase) +class OrderProperties(BaseModel): + datetime: DatetimeInterval + model_config = ConfigDict(extra="allow") -class Order(Feature[G, P]): + +class Order(Feature[Geometry, OrderProperties]): # We need to enforce that orders have an id defined, as that is required to # retrieve them via the API id: StrictInt | StrictStr # type: ignore type: Literal["Feature"] = "Feature" links: list[Link] + + +class OrderCollection(FeatureCollection[Order]): + type: Literal["FeatureCollection"] = "FeatureCollection" diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index 8ccaa71..fed6635 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -5,7 +5,7 @@ from stapi_fastapi.backends.root_backend import RootBackend from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON -from stapi_fastapi.models.order import Order +from stapi_fastapi.models.order import Order, OrderCollection from stapi_fastapi.models.product import Product, ProductsCollection from stapi_fastapi.models.root import RootResponse from stapi_fastapi.models.shared import Link @@ -112,7 +112,7 @@ def get_products(self, request: Request) -> ProductsCollection: ], ) - async def get_orders(self, request: Request) -> list[Order]: + async def get_orders(self, request: Request) -> OrderCollection: orders = await self.backend.get_orders(request) for order in orders: order.links.append( diff --git a/tests/backends.py b/tests/backends.py index a269c29..c619e64 100644 --- a/tests/backends.py +++ b/tests/backends.py @@ -4,7 +4,7 @@ from stapi_fastapi.backends.product_backend import ProductBackend from stapi_fastapi.exceptions import ConstraintsException, NotFoundException from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest -from stapi_fastapi.models.order import Order +from stapi_fastapi.models.order import Order, OrderCollection from stapi_fastapi.routers.product_router import ProductRouter @@ -16,7 +16,7 @@ class MockRootBackend: def __init__(self, orders: MockOrderDB) -> None: self._orders = orders - async def get_orders(self, request: Request) -> list[Order]: + async def get_orders(self, request: Request) -> OrderCollection: """ Show all orders. """ From 18c7be55a1746340539904a9bde59b512f8c0a10 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Fri, 1 Nov 2024 14:34:23 -0700 Subject: [PATCH 20/27] type the opporunities to the product constraints model --- src/stapi_fastapi/routers/product_router.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py index 36174cf..ac2ae4a 100644 --- a/src/stapi_fastapi/routers/product_router.py +++ b/src/stapi_fastapi/routers/product_router.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Self from fastapi import APIRouter, HTTPException, Request, Response, status +from geojson_pydantic.geometries import Geometry from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON from stapi_fastapi.exceptions import ConstraintsException @@ -46,6 +47,7 @@ def __init__( name=f"{self.root_router.name}:{self.product.id}:search-opportunities", methods=["POST"], response_class=GeoJSONResponse, + response_model=OpportunityCollection[Geometry, self.product.constraints], summary="Search Opportunities for the product", ) From 19fdf344f1df961895873fa36d203d64b5241b86 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Fri, 1 Nov 2024 14:34:51 -0700 Subject: [PATCH 21/27] upgrade fastapi --- poetry.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/poetry.lock b/poetry.lock index 673f934..8e25812 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "annotated-types" @@ -218,18 +218,18 @@ files = [ [[package]] name = "fastapi" -version = "0.115.0" +version = "0.115.4" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" files = [ - {file = "fastapi-0.115.0-py3-none-any.whl", hash = "sha256:17ea427674467486e997206a5ab25760f6b09e069f099b96f5b55a32fb6f1631"}, - {file = "fastapi-0.115.0.tar.gz", hash = "sha256:f93b4ca3529a8ebc6fc3fcf710e5efa8de3df9b41570958abf1d97d843138004"}, + {file = "fastapi-0.115.4-py3-none-any.whl", hash = "sha256:0b504a063ffb3cf96a5e27dc1bc32c80ca743a2528574f9cdc77daa2d31b4742"}, + {file = "fastapi-0.115.4.tar.gz", hash = "sha256:db653475586b091cb8b2fec2ac54a680ac6a158e07406e1abae31679e8826349"}, ] [package.dependencies] pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" -starlette = ">=0.37.2,<0.39.0" +starlette = ">=0.40.0,<0.42.0" typing-extensions = ">=4.8.0" [package.extras] @@ -995,13 +995,13 @@ files = [ [[package]] name = "starlette" -version = "0.38.6" +version = "0.41.2" description = "The little ASGI library that shines." optional = false python-versions = ">=3.8" files = [ - {file = "starlette-0.38.6-py3-none-any.whl", hash = "sha256:4517a1409e2e73ee4951214ba012052b9e16f60e90d73cfb06192c19203bbb05"}, - {file = "starlette-0.38.6.tar.gz", hash = "sha256:863a1588f5574e70a821dadefb41e4881ea451a47a3cd1b4df359d4ffefe5ead"}, + {file = "starlette-0.41.2-py3-none-any.whl", hash = "sha256:fbc189474b4731cf30fcef52f18a8d070e3f3b46c6a04c97579e85e6ffca942d"}, + {file = "starlette-0.41.2.tar.gz", hash = "sha256:9834fd799d1a87fd346deb76158668cfa0b0d56f85caefe8268e2d97c3468b62"}, ] [package.dependencies] From 2939a3e9cc06a6c8b01ed317b3ac31575bc0d0c8 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Fri, 1 Nov 2024 14:35:23 -0700 Subject: [PATCH 22/27] fix GeoJSONResponse model bug per openapi docs --- src/stapi_fastapi/responses.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/stapi_fastapi/responses.py b/src/stapi_fastapi/responses.py index e8f6e1a..4a6d551 100644 --- a/src/stapi_fastapi/responses.py +++ b/src/stapi_fastapi/responses.py @@ -4,6 +4,4 @@ class GeoJSONResponse(JSONResponse): - def __init__(self, *args, **kwargs) -> None: - kwargs["media_type"] = TYPE_GEOJSON - super().__init__(*args, **kwargs) + media_type = TYPE_GEOJSON From 6919ab51be48314a91f2af2a19c91297b5d5de90 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Fri, 1 Nov 2024 14:42:17 -0700 Subject: [PATCH 23/27] minimal test server to allow accessing openapi docs --- README.md | 23 +++++++---- bin/server.py | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 bin/server.py diff --git a/README.md b/README.md index cca7148..d62ab17 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # STAPI FastAPI - Sensor Tasking API with FastAPI -WARNING: The whole [STAPI spec](https://github.com/stapi-spec/stapi-spec) is -very much work in progress, so things are guaranteed to be not correct. +WARNING: The whole [STAPI spec] is very much work in progress, so things are +guaranteed to be not correct. ## Usage @@ -11,7 +11,7 @@ STAPI FastAPI provides an `fastapi.APIRouter` which must be included in ## Development It's 2024 and we still need to pick our poison for a 2024 dependency management -solution. This project picks [poetry][poetry] for now. +solution. This project picks [poetry] for now. ### Dev Setup @@ -35,15 +35,22 @@ command `pytest`. ### Dev Server This project cannot be run on its own because it does not have any backend -implementations. If a development server is desired, run one of the -implementations such as -[stapi-fastapi-tle](https://github.com/stapi-spec/stapi-fastapi-tle). To test -something like stapi-fastapi-tle with your development version of -stapi-fastapi, install them both into the same virtual environment. +implementations. However, a minimal test implementation is provided in +[`./bin/server.py`](./bin/server.py). It can be run with `uvicorn` as a way to +interact with the API and to view the OpenAPI documentation. Run it like so +from the project root: + +```commandline +uvicorn server:app --app-dir ./bin --reload +``` + +With the `uvicorn` defaults the app should be accessible at +`http://localhost:8000`. ### Implementing a backend - The test suite assumes the backend can be instantiated without any parameters required by the constructor. +[STAPI spec]: https://github.com/stapi-spec/stapi-spec [poetry]: https://python-poetry.org/ diff --git a/bin/server.py b/bin/server.py new file mode 100644 index 0000000..b9a2971 --- /dev/null +++ b/bin/server.py @@ -0,0 +1,108 @@ +from uuid import uuid4 + +from fastapi import FastAPI, Request +from stapi_fastapi.backends.product_backend import ProductBackend +from stapi_fastapi.exceptions import ConstraintsException, NotFoundException +from stapi_fastapi.models.opportunity import ( + Opportunity, + OpportunityPropertiesBase, + OpportunityRequest, +) +from stapi_fastapi.models.order import Order +from stapi_fastapi.models.product import ( + Product, + Provider, + ProviderRole, +) +from stapi_fastapi.routers.root_router import RootRouter + + +class MockOrderDB(dict[int | str, Order]): + pass + + +class MockRootBackend: + def __init__(self, orders: MockOrderDB) -> None: + self._orders: MockOrderDB = orders + + async def get_orders(self, request: Request) -> list[Order]: + """ + Show all orders. + """ + return list(self._orders.values()) + + async def get_order(self, order_id: str, request: Request) -> Order: + """ + Show details for order with `order_id`. + """ + try: + return self._orders[order_id] + except KeyError: + raise NotFoundException() + + +class MockProductBackend(ProductBackend): + def __init__(self, orders: MockOrderDB) -> None: + self._opportunities: list[Opportunity] = [] + self._allowed_payloads: list[OpportunityRequest] = [] + self._orders: MockOrderDB = orders + + async def search_opportunities( + self, product: Product, search: OpportunityRequest, request: Request + ) -> list[Opportunity]: + return [o.model_copy(update=search.model_dump()) for o in self._opportunities] + + async def create_order( + self, product: Product, payload: OpportunityRequest, request: Request + ) -> Order: + """ + Create a new order. + """ + allowed: bool = any(allowed == payload for allowed in self._allowed_payloads) + if allowed: + order = Order( + id=str(uuid4()), + geometry=payload.geometry, + properties={ + "filter": payload.filter, + "datetime": payload.datetime, + "product_id": product.id, + }, + links=[], + ) + self._orders[order.id] = order + return order + raise ConstraintsException("not allowed") + + +class TestSpotlightProperties(OpportunityPropertiesBase): + off_nadir: int + + +order_db = MockOrderDB() +product_backend = MockProductBackend(order_db) +root_backend = MockRootBackend(order_db) + +provider = Provider( + name="Test Provider", + description="A provider for Test data", + roles=[ProviderRole.producer], # Example role + url="https://test-provider.example.com", # Must be a valid URL +) + +product = Product( + id="test-spotlight", + title="Test Spotlight Product", + description="Test product for test spotlight", + license="CC-BY-4.0", + keywords=["test", "satellite"], + providers=[provider], + links=[], + constraints=TestSpotlightProperties, + backend=product_backend, +) + +root_router = RootRouter(root_backend) +root_router.add_product(product) +app: FastAPI = FastAPI() +app.include_router(root_router, prefix="") From 671d66aec6ab4d777e2e8e5af0d953ce4ff75bf8 Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Fri, 1 Nov 2024 16:23:09 -0700 Subject: [PATCH 24/27] remove nulls from serialized links --- src/stapi_fastapi/models/product.py | 2 +- src/stapi_fastapi/models/shared.py | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/stapi_fastapi/models/product.py b/src/stapi_fastapi/models/product.py index 563c448..4050751 100644 --- a/src/stapi_fastapi/models/product.py +++ b/src/stapi_fastapi/models/product.py @@ -76,6 +76,6 @@ def with_links(self: Self, links: list[Link] | None = None) -> Self: class ProductsCollection(BaseModel): - type: Literal["ProductCollection"] = "ProductCollection" + type_: Literal["ProductCollection"] = Field("ProductCollection", alias="type") links: list[Link] = Field(default_factory=list) products: list[Product] diff --git a/src/stapi_fastapi/models/shared.py b/src/stapi_fastapi/models/shared.py index cfabd84..f67c4e7 100644 --- a/src/stapi_fastapi/models/shared.py +++ b/src/stapi_fastapi/models/shared.py @@ -1,6 +1,12 @@ from typing import Any, Self -from pydantic import AnyUrl, BaseModel, ConfigDict +from pydantic import ( + AnyUrl, + BaseModel, + ConfigDict, + SerializerFunctionWrapHandler, + model_serializer, +) class Link(BaseModel): @@ -19,9 +25,8 @@ class Link(BaseModel): def __init__(self, href: AnyUrl | str, **kwargs): super().__init__(href=href, **kwargs) - def model_dump_json(self: Self, *args, **kwargs) -> str: - # TODO: this isn't working as expected and we get nulls in the output - # maybe need to override python dump too - # forcing the call to model_dump_json to exclude unset fields by default - kwargs["exclude_unset"] = kwargs.get("exclude_unset", True) - return super().model_dump_json(*args, **kwargs) + # overriding the default serialization to filter None field values from + # dumped json + @model_serializer(mode="wrap", when_used="json") + def serialize(self: Self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + return {k: v for k, v in handler(self).items() if v is not None} From f5f3dd2f7bdae0dedcf5fe730802eed9dfc9c5ee Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Fri, 1 Nov 2024 16:26:52 -0700 Subject: [PATCH 25/27] tests should fail not warn on spec incompatibility --- tests/product_test.py | 11 +++-------- tests/root_test.py | 41 ++++++++++------------------------------- tests/warnings.py | 2 -- 3 files changed, 13 insertions(+), 41 deletions(-) delete mode 100644 tests/warnings.py diff --git a/tests/product_test.py b/tests/product_test.py index eb89325..1d7c81e 100644 --- a/tests/product_test.py +++ b/tests/product_test.py @@ -1,11 +1,8 @@ -from warnings import warn - import pytest from fastapi import status from fastapi.testclient import TestClient from .utils import find_link -from .warnings import StapiSpecWarning def test_products_response(stapi_client: TestClient): @@ -32,11 +29,9 @@ def test_product_response_self_link( data = res.json() link = find_link(data["links"], "self") - if link is None: - warn(StapiSpecWarning("GET /products Link[rel=self] should exist")) - else: - assert link["type"] == "application/json" - assert link["href"] == url_for(f"/products/{product_id}") + assert link, "GET /products Link[rel=self] should exist" + assert link["type"] == "application/json" + assert link["href"] == url_for(f"/products/{product_id}") @pytest.mark.parametrize("product_id", ["test-spotlight"]) diff --git a/tests/root_test.py b/tests/root_test.py index e1257df..5fa42bf 100644 --- a/tests/root_test.py +++ b/tests/root_test.py @@ -1,24 +1,10 @@ -from warnings import warn - from fastapi import status from fastapi.testclient import TestClient -from pytest import fixture from .utils import find_link -from .warnings import StapiSpecWarning -@fixture -def data(stapi_client: TestClient): - res = stapi_client.get("/") - - assert res.status_code == status.HTTP_200_OK - assert res.headers["Content-Type"] == "application/json" - - yield res.json() - - -def test_root(stapi_client: TestClient, url_for): +def test_root(stapi_client: TestClient, url_for) -> None: res = stapi_client.get("/") assert res.status_code == status.HTTP_200_OK @@ -27,23 +13,16 @@ def test_root(stapi_client: TestClient, url_for): data = res.json() link = find_link(data["links"], "self") - if link is None: - warn(StapiSpecWarning("GET / Link[rel=self] should exist")) - else: - assert link["type"] == "application/json" - assert link["href"] == url_for("/") + assert link, "GET / Link[rel=self] should exist" + assert link["type"] == "application/json" + assert link["href"] == url_for("/") link = find_link(data["links"], "service-description") - if link is None: - warn(StapiSpecWarning("GET / Link[rel=service-description] should exist")) - - else: - assert link["type"] == "application/json" - assert str(link["href"]) == url_for("/openapi.json") + assert link, "GET / Link[rel=service-description] should exist" + assert link["type"] == "application/json" + assert str(link["href"]) == url_for("/openapi.json") link = find_link(data["links"], "service-docs") - if link is None: - warn(StapiSpecWarning("GET / Link[rel=service-docs] should exist")) - else: - assert link["type"] == "text/html" - assert str(link["href"]) == url_for("/docs") + assert link, "GET / Link[rel=service-docs] should exist" + assert link["type"] == "text/html" + assert str(link["href"]) == url_for("/docs") diff --git a/tests/warnings.py b/tests/warnings.py deleted file mode 100644 index a8b7062..0000000 --- a/tests/warnings.py +++ /dev/null @@ -1,2 +0,0 @@ -class StapiSpecWarning(Warning): - pass From 5948e221c2e52be3f2cd92f4b4d676c89f745a5d Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Fri, 1 Nov 2024 16:28:28 -0700 Subject: [PATCH 26/27] add nocover to protocol implementations --- src/stapi_fastapi/backends/product_backend.py | 2 +- src/stapi_fastapi/backends/root_backend.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/stapi_fastapi/backends/product_backend.py b/src/stapi_fastapi/backends/product_backend.py index e2e0ce0..e24ce08 100644 --- a/src/stapi_fastapi/backends/product_backend.py +++ b/src/stapi_fastapi/backends/product_backend.py @@ -9,7 +9,7 @@ from stapi_fastapi.routers.product_router import ProductRouter -class ProductBackend(Protocol): +class ProductBackend(Protocol): # pragma: nocover async def search_opportunities( self, product_router: ProductRouter, diff --git a/src/stapi_fastapi/backends/root_backend.py b/src/stapi_fastapi/backends/root_backend.py index a4ee60c..b2831d9 100644 --- a/src/stapi_fastapi/backends/root_backend.py +++ b/src/stapi_fastapi/backends/root_backend.py @@ -5,7 +5,7 @@ from stapi_fastapi.models.order import Order, OrderCollection -class RootBackend(Protocol): +class RootBackend(Protocol): # pragma: nocover async def get_orders(self, request: Request) -> OrderCollection: """ Return a list of existing orders. From ba130c4b7925ed7df344e14f1af96d820162cbdd Mon Sep 17 00:00:00 2001 From: Jarrett Keifer Date: Fri, 1 Nov 2024 16:31:55 -0700 Subject: [PATCH 27/27] standardize links on models --- src/stapi_fastapi/models/opportunity.py | 5 +++-- src/stapi_fastapi/models/order.py | 5 +++-- src/stapi_fastapi/models/product.py | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/stapi_fastapi/models/opportunity.py b/src/stapi_fastapi/models/opportunity.py index f5f5cf4..ed10fed 100644 --- a/src/stapi_fastapi/models/opportunity.py +++ b/src/stapi_fastapi/models/opportunity.py @@ -2,7 +2,7 @@ from geojson_pydantic import Feature, FeatureCollection from geojson_pydantic.geometries import Geometry -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from stapi_fastapi.models.shared import Link from stapi_fastapi.types.datetime_interval import DatetimeInterval @@ -29,8 +29,9 @@ class OpportunityRequest(BaseModel): class Opportunity(Feature[G, P]): type: Literal["Feature"] = "Feature" - links: list[Link] = [] + links: list[Link] = Field(default_factory=list) class OpportunityCollection(FeatureCollection[Opportunity[G, P]]): type: Literal["FeatureCollection"] = "FeatureCollection" + links: list[Link] = Field(default_factory=list) diff --git a/src/stapi_fastapi/models/order.py b/src/stapi_fastapi/models/order.py index 476d82d..f511a55 100644 --- a/src/stapi_fastapi/models/order.py +++ b/src/stapi_fastapi/models/order.py @@ -2,7 +2,7 @@ from geojson_pydantic import Feature, FeatureCollection from geojson_pydantic.geometries import Geometry -from pydantic import BaseModel, ConfigDict, StrictInt, StrictStr +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr from stapi_fastapi.models.shared import Link from stapi_fastapi.types.datetime_interval import DatetimeInterval @@ -18,8 +18,9 @@ class Order(Feature[Geometry, OrderProperties]): # retrieve them via the API id: StrictInt | StrictStr # type: ignore type: Literal["Feature"] = "Feature" - links: list[Link] + links: list[Link] = Field(default_factory=list) class OrderCollection(FeatureCollection[Order]): type: Literal["FeatureCollection"] = "FeatureCollection" + links: list[Link] = Field(default_factory=list) diff --git a/src/stapi_fastapi/models/product.py b/src/stapi_fastapi/models/product.py index 4050751..27820fe 100644 --- a/src/stapi_fastapi/models/product.py +++ b/src/stapi_fastapi/models/product.py @@ -41,7 +41,7 @@ class Product(BaseModel): keywords: list[str] = Field(default_factory=list) license: str providers: list[Provider] = Field(default_factory=list) - links: list[Link] + links: list[Link] = Field(default_factory=list) # we don't want to include these in the model fields _constraints: type[OpportunityPropertiesBase]