From e2572dc57888c62ae0c07680d6349a7e522a4fd3 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Mon, 27 Jan 2025 08:49:04 -0600 Subject: [PATCH 01/13] feat: convert backend protocols to callables This change is in preparation for making product opportunity searching and ordering optional and for introducing optional asynchronous opportunity searching (which will require state). A few notable changes to the test suite: - The `backends` module has been removed from the `tests` directory. Its contents were never actually being used. The contents in `conftest` took precedence. - The in-memory storage that was attached to the MockProductBackend class is replaced with a FastAPI lifespan for storing state. --- src/stapi_fastapi/__init__.py | 10 +- src/stapi_fastapi/backends/__init__.py | 11 +- src/stapi_fastapi/backends/product_backend.py | 68 ++++--- src/stapi_fastapi/backends/root_backend.py | 64 +++--- src/stapi_fastapi/models/product.py | 22 ++- src/stapi_fastapi/routers/product_router.py | 4 +- src/stapi_fastapi/routers/root_router.py | 16 +- tests/application.py | 186 ++++++++++-------- tests/backends.py | 80 -------- tests/conftest.py | 56 +++--- tests/test_opportunity.py | 10 +- tests/test_order.py | 41 ++-- 12 files changed, 271 insertions(+), 297 deletions(-) delete mode 100644 tests/backends.py diff --git a/src/stapi_fastapi/__init__.py b/src/stapi_fastapi/__init__.py index 3a94221..a18092b 100644 --- a/src/stapi_fastapi/__init__.py +++ b/src/stapi_fastapi/__init__.py @@ -1,4 +1,5 @@ -from .backends import ProductBackend, RootBackend +from .backends.product_backend import CreateOrder, SearchOpportunities +from .backends.root_backend import GetOrder, GetOrders, GetOrderStatuses from .models import ( Link, OpportunityProperties, @@ -9,13 +10,16 @@ from .routers import ProductRouter, RootRouter __all__ = [ + "CreateOrder", + "GetOrder", + "GetOrders", + "GetOrderStatuses", "Link", "OpportunityProperties", "Product", - "ProductBackend", "ProductRouter", "Provider", "ProviderRole", - "RootBackend", "RootRouter", + "SearchOpportunities", ] diff --git a/src/stapi_fastapi/backends/__init__.py b/src/stapi_fastapi/backends/__init__.py index b4b7ff2..b2fb899 100644 --- a/src/stapi_fastapi/backends/__init__.py +++ b/src/stapi_fastapi/backends/__init__.py @@ -1,7 +1,10 @@ -from .product_backend import ProductBackend -from .root_backend import RootBackend +from .product_backend import CreateOrder, SearchOpportunities +from .root_backend import GetOrder, GetOrders, GetOrderStatuses __all__ = [ - "ProductBackend", - "RootBackend", + "CreateOrder", + "GetOrder", + "GetOrders", + "GetOrderStatuses", + "SearchOpportunities", ] diff --git a/src/stapi_fastapi/backends/product_backend.py b/src/stapi_fastapi/backends/product_backend.py index 0172500..fd46ff4 100644 --- a/src/stapi_fastapi/backends/product_backend.py +++ b/src/stapi_fastapi/backends/product_backend.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Protocol +from typing import Any, Callable, Coroutine from fastapi import Request from returns.result import ResultE @@ -9,30 +9,42 @@ from stapi_fastapi.models.order import Order, OrderPayload from stapi_fastapi.routers.product_router import ProductRouter - -class ProductBackend(Protocol): # pragma: nocover - async def search_opportunities( - self, - product_router: ProductRouter, - search: OpportunityRequest, - request: Request, - ) -> ResultE[list[Opportunity]]: - """ - Search for ordering opportunities for the given search parameters. - - Backends must validate search constraints and return - `stapi_fastapi.exceptions.ConstraintsException` if not valid. - """ - - async def create_order( - self, - product_router: ProductRouter, - search: OrderPayload, - request: Request, - ) -> ResultE[Order]: - """ - Create a new order. - - Backends must validate order payload and return - `stapi_fastapi.exceptions.ConstraintsException` if not valid. - """ +SearchOpportunities = Callable[ + [ProductRouter, OpportunityRequest, Request], + Coroutine[Any, Any, ResultE[list[Opportunity]]], +] +""" +Type alias for an async function that searches for ordering opportunities for the given +search parameters. + +Args: + product_router (ProductRouter): The product router. + search (OpportunityRequest): The search parameters. + request (Request): FastAPI's Request object. + +Returns: + - Should return returns.result.Success[list[Opportunity]] + - Returning returns.result.Failure[Exception] will result in a 500. + +Backends must validate search constraints and return +returns.result.Failure[stapi_fastapi.exceptions.ConstraintsException] if not valid. +""" + +CreateOrder = Callable[ + [ProductRouter, OrderPayload, Request], Coroutine[Any, Any, ResultE[Order]] +] +""" +Type alias for an async function that creates a new order. + +Args: + product_router (ProductRouter): The product router. + payload (OrderPayload): The order payload. + request (Request): FastAPI's Request object. + +Returns: + - Should return returns.result.Success[Order] + - Returning returns.result.Failure[Exception] will result in a 500. + +Backends must validate order payload and return +returns.result.Failure[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 5b77e2f..0212b8c 100644 --- a/src/stapi_fastapi/backends/root_backend.py +++ b/src/stapi_fastapi/backends/root_backend.py @@ -1,4 +1,4 @@ -from typing import Protocol +from typing import Any, Callable, Coroutine, TypeVar from fastapi import Request from returns.maybe import Maybe @@ -10,38 +10,48 @@ OrderStatus, ) +GetOrders = Callable[[Request], Coroutine[Any, Any, ResultE[OrderCollection]]] +""" +Type alias for an async function that returns a list of existing Orders. -class RootBackend[T: OrderStatus](Protocol): # pragma: nocover - async def get_orders(self, request: Request) -> ResultE[OrderCollection]: - """ - Return a list of existing orders. - """ - ... +Args: + request (Request): FastAPI's Request object. - async def get_order(self, order_id: str, request: Request) -> ResultE[Maybe[Order]]: - """ - Get details for order with `order_id`. +Returns: + - Should return returns.result.Success[OrderCollection] + - Returning returns.result.Failure[Exception] will result in a 500. +""" - Should return returns.results.Success[Order] if order is found. +GetOrder = Callable[[str, Request], Coroutine[Any, Any, ResultE[Maybe[Order]]]] +""" +Type alias for an async function that gets details for the order with `order_id`. - Should return returns.results.Failure[returns.maybe.Nothing] if the order is - not found or if access is denied. +Args: + order_id (str): The order ID. + request (Request): FastAPI's Request object. - A Failure[Exception] will result in a 500. - """ - ... +Returns: + - Should return returns.result.Success[Order] if order is found. + - Should return returns.result.Failure[returns.maybe.Nothing] if the order is not + found or if access is denied. + - Returning returns.result.Failure[Exception] will result in a 500. +""" - async def get_order_statuses( - self, order_id: str, request: Request - ) -> ResultE[list[T]]: - """ - Get statuses for order with `order_id`. - Should return returns.results.Success[list[OrderStatus]] if order is found. +T = TypeVar("T", bound=OrderStatus) - Should return returns.results.Failure[Exception] if the order is - not found or if access is denied. - A Failure[Exception] will result in a 500. - """ - ... +GetOrderStatuses = Callable[[str, Request], Coroutine[Any, Any, ResultE[list[T]]]] +""" +Type alias for an async function that gets statuses for the order with `order_id`. + +Args: + order_id (str): The order ID. + request (Request): FastAPI's Request object. + +Returns: + - Should return returns.result.Success[list[OrderStatus]] if order is found. + - Should return returns.result.Failure[Exception] if the order is not found or if + access is denied. + - Returning returns.result.Failure[Exception] will result in a 500. +""" diff --git a/src/stapi_fastapi/models/product.py b/src/stapi_fastapi/models/product.py index bf5118a..d81e79f 100644 --- a/src/stapi_fastapi/models/product.py +++ b/src/stapi_fastapi/models/product.py @@ -10,7 +10,10 @@ from stapi_fastapi.models.shared import Link if TYPE_CHECKING: - from stapi_fastapi.backends.product_backend import ProductBackend + from stapi_fastapi.backends.product_backend import ( + CreateOrder, + SearchOpportunities, + ) type Constraints = BaseModel @@ -50,26 +53,33 @@ class Product(BaseModel): _constraints: type[Constraints] _opportunity_properties: type[OpportunityProperties] _order_parameters: type[OrderParameters] - _backend: ProductBackend + _create_order: CreateOrder + _search_opportunities: SearchOpportunities def __init__( self, *args, - backend: ProductBackend, + create_order: CreateOrder, + search_opportunities: SearchOpportunities, constraints: type[Constraints], opportunity_properties: type[OpportunityProperties], order_parameters: type[OrderParameters], **kwargs, ) -> None: super().__init__(*args, **kwargs) - self._backend = backend + self._create_order = create_order + self._search_opportunities = search_opportunities self._constraints = constraints self._opportunity_properties = opportunity_properties self._order_parameters = order_parameters @property - def backend(self: Self) -> ProductBackend: - return self._backend + def create_order(self: Self) -> CreateOrder: + return self._create_order + + @property + def search_opportunities(self: Self) -> SearchOpportunities: + return self._search_opportunities @property def constraints(self: Self) -> type[Constraints]: diff --git a/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py index e98738c..18c85aa 100644 --- a/src/stapi_fastapi/routers/product_router.py +++ b/src/stapi_fastapi/routers/product_router.py @@ -166,7 +166,7 @@ async def search_opportunities( """ Explore the opportunities available for a particular set of constraints """ - match await self.product.backend.search_opportunities(self, search, request): + match await self.product.search_opportunities(self, search, request): case Success(features): return OpportunityCollection( features=features, @@ -214,7 +214,7 @@ async def create_order( """ Create a new order. """ - match await self.product.backend.create_order( + match await self.product.create_order( self, payload, request, diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index fa77604..e2e2a9a 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -7,7 +7,7 @@ from returns.maybe import Maybe, Some from returns.result import Failure, Success -from stapi_fastapi.backends.root_backend import RootBackend +from stapi_fastapi.backends.root_backend import GetOrder, GetOrders, GetOrderStatuses from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON from stapi_fastapi.exceptions import NotFoundException from stapi_fastapi.models.conformance import CORE, Conformance @@ -28,7 +28,9 @@ class RootRouter(APIRouter): def __init__( self, - backend: RootBackend, + get_orders: GetOrders, + get_order: GetOrder, + get_order_statuses: GetOrderStatuses, conformances: list[str] = [CORE], name: str = "root", openapi_endpoint_name: str = "openapi", @@ -37,7 +39,9 @@ def __init__( **kwargs, ) -> None: super().__init__(*args, **kwargs) - self.backend = backend + self._get_orders = get_orders + self._get_order = get_order + self._get_order_statuses = get_order_statuses self.name = name self.conformances = conformances self.openapi_endpoint_name = openapi_endpoint_name @@ -153,7 +157,7 @@ def get_products(self, request: Request) -> ProductsCollection: ) async def get_orders(self, request: Request) -> OrderCollection: - match await self.backend.get_orders(request): + match await self._get_orders(request): case Success(orders): for order in orders: order.links.append( @@ -184,7 +188,7 @@ async def get_order(self: Self, order_id: str, request: Request) -> Order: """ Get details for order with `order_id`. """ - match await self.backend.get_order(order_id, request): + match await self._get_order(order_id, request): case Success(Some(order)): self.add_order_links(order, request) return order @@ -206,7 +210,7 @@ async def get_order(self: Self, order_id: str, request: Request) -> Order: async def get_order_statuses( self: Self, order_id: str, request: Request ) -> OrderStatuses: - match await self.backend.get_order_statuses(order_id, request): + match await self._get_order_statuses(order_id, request): case Success(statuses): return OrderStatuses( statuses=statuses, diff --git a/tests/application.py b/tests/application.py index 4329517..eca6ee2 100644 --- a/tests/application.py +++ b/tests/application.py @@ -1,6 +1,8 @@ from collections import defaultdict +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from datetime import datetime, timezone -from typing import Literal, Self +from typing import Any, Literal, Self from uuid import uuid4 from fastapi import FastAPI, Request @@ -8,8 +10,6 @@ from returns.maybe import Maybe from returns.result import Failure, ResultE, Success -from stapi_fastapi.backends.product_backend import ProductBackend -from stapi_fastapi.backends.root_backend import RootBackend from stapi_fastapi.models.conformance import CORE from stapi_fastapi.models.opportunity import ( Opportunity, @@ -21,6 +21,8 @@ OrderCollection, OrderParameters, OrderPayload, + OrderProperties, + OrderSearchParameters, OrderStatus, OrderStatusCode, ) @@ -38,85 +40,82 @@ class InMemoryOrderDB: _statuses: dict[str, list[OrderStatus]] = defaultdict(list) -class MockRootBackend(RootBackend): - def __init__(self, orders: InMemoryOrderDB) -> None: - self._orders_db: InMemoryOrderDB = orders - - async def get_orders(self, request: Request) -> ResultE[OrderCollection]: - """ - Show all orders. - """ - return Success(OrderCollection(features=list(self._orders_db._orders.values()))) - - async def get_order(self, order_id: str, request: Request) -> ResultE[Maybe[Order]]: - """ - Show details for order with `order_id`. - """ - - return Success(Maybe.from_optional(self._orders_db._orders.get(order_id))) - - async def get_order_statuses( - self, order_id: str, request: Request - ) -> ResultE[list[OrderStatus]]: - return Success(self._orders_db._statuses[order_id]) - - -class MockProductBackend(ProductBackend): - def __init__(self, orders: InMemoryOrderDB) -> None: - self._opportunities: list[Opportunity] = [] - self._allowed_payloads: list[OrderPayload] = [] - self._orders_db: InMemoryOrderDB = orders - - async def search_opportunities( - self, - product_router: ProductRouter, - search: OpportunityRequest, - request: Request, - ) -> ResultE[list[Opportunity]]: - try: - return Success( - [o.model_copy(update=search.model_dump()) for o in self._opportunities] - ) - except Exception as e: - return Failure(e) - - async def create_order( - self, product_router: ProductRouter, payload: OrderPayload, request: Request - ) -> ResultE[Order]: - """ - Create a new order. - """ - try: - status = OrderStatus( - timestamp=datetime.now(timezone.utc), - status_code=OrderStatusCode.received, - ) - order = Order( - id=str(uuid4()), - geometry=payload.geometry, - properties={ - "product_id": product_router.product.id, - "created": datetime.now(timezone.utc), - "status": status, - "search_parameters": { - "geometry": payload.geometry, - "datetime": payload.datetime, - "filter": payload.filter, - }, - "order_parameters": payload.order_parameters.model_dump(), - "opportunity_properties": { - "datetime": "2024-01-29T12:00:00Z/2024-01-30T12:00:00Z", - "off_nadir": 10, - }, +async def mock_get_orders(request: Request) -> ResultE[OrderCollection]: + """ + Show all orders. + """ + return Success( + OrderCollection(features=list(request.state._orders_db._orders.values())) + ) + + +async def mock_get_order(order_id: str, request: Request) -> ResultE[Maybe[Order]]: + """ + Show details for order with `order_id`. + """ + + return Success(Maybe.from_optional(request.state._orders_db._orders.get(order_id))) + + +async def mock_get_order_statuses( + order_id: str, request: Request +) -> ResultE[list[OrderStatus]]: + return Success(request.state._orders_db._statuses[order_id]) + + +async def mock_search_opportunities( + product_router: ProductRouter, + search: OpportunityRequest, + request: Request, +) -> ResultE[list[Opportunity]]: + try: + return Success( + [ + o.model_copy(update=search.model_dump()) + for o in request.state._opportunities + ] + ) + except Exception as e: + return Failure(e) + + +async def mock_create_order( + product_router: ProductRouter, payload: OrderPayload, request: Request +) -> ResultE[Order]: + """ + Create a new order. + """ + try: + status = OrderStatus( + timestamp=datetime.now(timezone.utc), + status_code=OrderStatusCode.received, + ) + order = Order( + id=str(uuid4()), + geometry=payload.geometry, + properties=OrderProperties( + product_id=product_router.product.id, + created=datetime.now(timezone.utc), + status=status, + search_parameters=OrderSearchParameters( + geometry=payload.geometry, + datetime=payload.datetime, + filter=payload.filter, + ), + order_parameters=payload.order_parameters.model_dump(), + opportunity_properties={ + "datetime": "2024-01-29T12:00:00Z/2024-01-30T12:00:00Z", + "off_nadir": 10, }, - links=[], - ) + ), + links=[], + ) - self._orders_db._orders[order.id] = order - self._orders_db._statuses[order.id].insert(0, status) - return Success(order) - except Exception as e: - return Failure(e) + request.state._orders_db._orders[order.id] = order + request.state._orders_db._statuses[order.id].insert(0, status) + return Success(order) + except Exception as e: + return Failure(e) class MyProductConstraints(BaseModel): @@ -144,10 +143,6 @@ class MyOrderParameters(OrderParameters): s3_path: str | None = None -order_db = InMemoryOrderDB() -product_backend = MockProductBackend(order_db) -root_backend = MockRootBackend(order_db) - provider = Provider( name="Test Provider", description="A provider for Test data", @@ -163,13 +158,30 @@ class MyOrderParameters(OrderParameters): keywords=["test", "satellite"], providers=[provider], links=[], + create_order=mock_create_order, + search_opportunities=mock_search_opportunities, constraints=MyProductConstraints, opportunity_properties=MyOpportunityProperties, order_parameters=MyOrderParameters, - backend=product_backend, ) -root_router = RootRouter(root_backend, conformances=[CORE]) + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: + try: + yield { + "_orders_db": InMemoryOrderDB(), + } + finally: + pass + + +root_router = RootRouter( + get_orders=mock_get_orders, + get_order=mock_get_order, + get_order_statuses=mock_get_order_statuses, + conformances=[CORE], +) root_router.add_product(product) -app: FastAPI = FastAPI() +app: FastAPI = FastAPI(lifespan=lifespan) app.include_router(root_router, prefix="") diff --git a/tests/backends.py b/tests/backends.py deleted file mode 100644 index ede3655..0000000 --- a/tests/backends.py +++ /dev/null @@ -1,80 +0,0 @@ -from datetime import datetime, timezone -from uuid import uuid4 - -from fastapi import Request -from returns.result import Failure, ResultE, Success - -from stapi_fastapi.backends.product_backend import ProductBackend -from stapi_fastapi.exceptions import ConstraintsException -from stapi_fastapi.models.opportunity import ( - Opportunity, - OpportunityRequest, -) -from stapi_fastapi.models.order import ( - Order, - OrderPayload, - OrderStatus, - OrderStatusCode, -) -from stapi_fastapi.routers.product_router import ProductRouter - -from .application import InMemoryOrderDB - - -class MockProductBackend(ProductBackend): - def __init__(self, orders: InMemoryOrderDB) -> None: - self._opportunities: list[Opportunity] = [] - self._allowed_payloads: list[OrderPayload] = [] - self._orders = orders - - async def search_opportunities( - self, - product_router: ProductRouter, - search: OpportunityRequest, - request: Request, - ) -> ResultE[list[Opportunity]]: - return Success( - [o.model_copy(update=search.model_dump()) for o in self._opportunities] - ) - - async def create_order( - self, - product_router: ProductRouter, - payload: OrderPayload, - request: Request, - ) -> ResultE[Order]: - """ - Create a new order. - """ - if any(allowed == payload for allowed in self._allowed_payloads): - order = Order( - id=str(uuid4()), - geometry=payload.geometry, # maybe set to a different value by opportunity resolution process - properties={ - "product_id": product_router.product.id, - "created": datetime.now(timezone.utc), - "status": OrderStatus( - timestamp=datetime.now(timezone.utc), - status_code=OrderStatusCode.accepted, - ), - "search_parameters": { - "geometry": payload.geometry, - "datetime": payload.datetime, - "filter": payload.filter, - }, - "order_parameters": payload.order_parameters.model_dump(), - "opportunity_properties": { - "datetime": "2024-01-29T12:00:00Z/2024-01-30T12:00:00Z", - "off_nadir": 10, - }, - }, - links=[], - ) - self._orders[order.id] = order - return Success(order) - else: - return Failure( - ConstraintsException( - f"not allowed: payload {payload.model_dump_json()} not in {[p.model_dump_json() for p in self._allowed_payloads]}" - ) - ) diff --git a/tests/conftest.py b/tests/conftest.py index 82eb841..b824dfb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ -from collections.abc import Iterator +from collections.abc import AsyncIterator, Iterator +from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone from typing import Any, Callable from urllib.parse import urljoin @@ -22,11 +23,15 @@ from .application import ( InMemoryOrderDB, - MockProductBackend, - MockRootBackend, MyOpportunityProperties, MyOrderParameters, MyProductConstraints, + OffNadirRange, + mock_create_order, + mock_get_order, + mock_get_order_statuses, + mock_get_orders, + mock_search_opportunities, ) from .shared import find_link @@ -36,24 +41,9 @@ def base_url() -> Iterator[str]: yield "http://stapiserver" -@pytest.fixture -def order_db() -> InMemoryOrderDB: - return InMemoryOrderDB() - - -@pytest.fixture -def product_backend(order_db: InMemoryOrderDB) -> MockProductBackend: - return MockProductBackend(order_db) - - -@pytest.fixture -def root_backend(order_db: InMemoryOrderDB) -> MockRootBackend: - return MockRootBackend(order_db) - - @pytest.fixture def mock_product_test_spotlight( - product_backend: MockProductBackend, mock_provider: Provider + mock_provider: Provider, ) -> Product: """Fixture for a mock Test Spotlight product.""" return Product( @@ -67,17 +57,31 @@ def mock_product_test_spotlight( constraints=MyProductConstraints, opportunity_properties=MyOpportunityProperties, order_parameters=MyOrderParameters, - backend=product_backend, + create_order=mock_create_order, + search_opportunities=mock_search_opportunities, ) +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: + try: + yield { + "_orders_db": InMemoryOrderDB(), + "_opportunities": [], + } + finally: + pass + + @pytest.fixture -def stapi_client( - root_backend, mock_product_test_spotlight, base_url: str -) -> Iterator[TestClient]: - root_router = RootRouter(root_backend) +def stapi_client(mock_product_test_spotlight, base_url: str) -> Iterator[TestClient]: + root_router = RootRouter( + get_orders=mock_get_orders, + get_order=mock_get_order, + get_order_statuses=mock_get_order_statuses, + ) root_router.add_product(mock_product_test_spotlight) - app = FastAPI() + app = FastAPI(lifespan=lifespan) app.include_router(root_router, prefix="") with TestClient(app, base_url=f"{base_url}") as client: @@ -146,7 +150,7 @@ def mock_test_spotlight_opportunities() -> list[Opportunity]: properties=MyOpportunityProperties( product_id="xyz123", datetime=(start, end), - off_nadir={"minimum": 20, "maximum": 22}, + off_nadir=OffNadirRange(minimum=20, maximum=22), vehicle_id=[1], platform="platform_id", other_thing="abcd1234", diff --git a/tests/test_opportunity.py b/tests/test_opportunity.py index 29ba4d1..84b31cc 100644 --- a/tests/test_opportunity.py +++ b/tests/test_opportunity.py @@ -1,24 +1,21 @@ from datetime import UTC, datetime, timedelta -from typing import List import pytest from fastapi.testclient import TestClient from stapi_fastapi.models.opportunity import Opportunity, OpportunityCollection -from .backends import MockProductBackend from .test_datetime_interval import rfc3339_strftime @pytest.mark.parametrize("product_id", ["test-spotlight"]) def test_search_opportunities_response( product_id: str, - mock_test_spotlight_opportunities: List[Opportunity], - product_backend: MockProductBackend, + mock_test_spotlight_opportunities: list[Opportunity], stapi_client: TestClient, assert_link, ) -> None: - product_backend._opportunities = mock_test_spotlight_opportunities + stapi_client.app_state["_opportunities"] = mock_test_spotlight_opportunities now = datetime.now(UTC) start = now @@ -53,6 +50,9 @@ def test_search_opportunities_response( assert response.status_code == 200, f"Failed for product: {product_id}" body = response.json() + # Validate the opportunity was returned + assert len(body["features"]) == 1 + try: _ = OpportunityCollection(**body) except Exception as _: diff --git a/tests/test_order.py b/tests/test_order.py index d5fe5a2..e8d81ab 100644 --- a/tests/test_order.py +++ b/tests/test_order.py @@ -10,7 +10,6 @@ from stapi_fastapi.models.order import OrderPayload from .application import MyOrderParameters -from .backends import MockProductBackend from .shared import find_link NOW = datetime.now(UTC) @@ -19,34 +18,29 @@ @pytest.fixture -def create_order_allowed_payloads() -> list[OrderPayload]: - return [ - OrderPayload( - geometry=Point( - type="Point", coordinates=Position2D(longitude=13.4, latitude=52.5) - ), - datetime=( - datetime.fromisoformat("2024-11-11T18:55:33Z"), - datetime.fromisoformat("2024-11-15T18:55:33Z"), - ), - filter=None, - order_parameters=MyOrderParameters(s3_path="s3://my-bucket"), +def create_order_payload() -> OrderPayload: + return OrderPayload( + geometry=Point( + type="Point", coordinates=Position2D(longitude=13.4, latitude=52.5) ), - ] + datetime=( + datetime.fromisoformat("2024-11-11T18:55:33Z"), + datetime.fromisoformat("2024-11-15T18:55:33Z"), + ), + filter=None, + order_parameters=MyOrderParameters(s3_path="s3://my-bucket"), + ) @pytest.fixture def new_order_response( product_id: str, - product_backend: MockProductBackend, stapi_client: TestClient, - create_order_allowed_payloads: list[OrderPayload], + create_order_payload: OrderPayload, ) -> Response: - product_backend._allowed_payloads = create_order_allowed_payloads - res = stapi_client.post( f"products/{product_id}/orders", - json=create_order_allowed_payloads[0].model_dump(), + json=create_order_payload.model_dump(), ) assert res.status_code == status.HTTP_201_CREATED, res.text @@ -97,23 +91,23 @@ def get_order_response( @pytest.mark.parametrize("product_id", ["test-spotlight"]) def test_get_order_properties( - get_order_response: Response, create_order_allowed_payloads + get_order_response: Response, create_order_payload ) -> None: order = get_order_response.json() assert order["geometry"] == { "type": "Point", - "coordinates": list(create_order_allowed_payloads[0].geometry.coordinates), + "coordinates": list(create_order_payload.geometry.coordinates), } assert order["properties"]["search_parameters"]["geometry"] == { "type": "Point", - "coordinates": list(create_order_allowed_payloads[0].geometry.coordinates), + "coordinates": list(create_order_payload.geometry.coordinates), } assert ( order["properties"]["search_parameters"]["datetime"] - == create_order_allowed_payloads[0].model_dump()["datetime"] + == create_order_payload.model_dump()["datetime"] ) @@ -126,6 +120,7 @@ def test_order_status_after_create( f"GET /orders/{body['id']}", body, "monitor", f"/orders/{body['id']}/statuses" ) link = find_link(body["links"], "monitor") + assert link is not None res = stapi_client.get(link["href"]) assert res.status_code == status.HTTP_200_OK From 8419715bb3229cbe82b2601f959b36c598e5e485 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Mon, 27 Jan 2025 10:30:49 -0600 Subject: [PATCH 02/13] docs: update CHANGELOG --- CHANGELOG.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2580740..ba2833b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- Replaced the root and product backend Protocol classes with Callable type aliases to + enable future changes to make product opportunity searching, product ordering, and/or + asynchronous (stateful) product opportunity searching optional. ## [v0.5.0] - 2025-01-08 @@ -64,7 +71,6 @@ none none - ## [v0.3.0] - 2024-12-6 ### Added @@ -75,7 +81,7 @@ none - OrderStatusCode and ProviderRole are now StrEnum instead of (str, Enum) - All types using `Result[A, Exception]` have been replace with the equivalent type `ResultE[A]` -- Order and OrderCollection extend _GeoJsonBase instead of Feature and FeatureCollection, to allow for tighter +- Order and OrderCollection extend \_GeoJsonBase instead of Feature and FeatureCollection, to allow for tighter constraints on fields ### Deprecated @@ -140,7 +146,7 @@ Initial release - Add link `create-order` to OpportunityCollection [unreleased]: https://github.com/stapi-spec/stapi-fastapi/compare/v0.5.0...main -[v0.4.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.5.0 +[v0.5.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.5.0 [v0.4.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.4.0 [v0.3.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.3.0 [v0.2.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.2.0 From 32920e7c9bf4d8540fac946614df4650426f93f7 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Mon, 27 Jan 2025 13:16:01 -0600 Subject: [PATCH 03/13] review: correct return type in root backend docstrings --- src/stapi_fastapi/backends/root_backend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/stapi_fastapi/backends/root_backend.py b/src/stapi_fastapi/backends/root_backend.py index 0212b8c..d869a00 100644 --- a/src/stapi_fastapi/backends/root_backend.py +++ b/src/stapi_fastapi/backends/root_backend.py @@ -31,8 +31,8 @@ request (Request): FastAPI's Request object. Returns: - - Should return returns.result.Success[Order] if order is found. - - Should return returns.result.Failure[returns.maybe.Nothing] if the order is not + - Should return returns.result.Success[returns.maybe.Some[Order]] if order is found. + - Should return returns.result.Success[returns.maybe.Nothing] if the order is not found or if access is denied. - Returning returns.result.Failure[Exception] will result in a 500. """ From 528a5130d78cb07d5ff969b2e6948687213d3594 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Mon, 27 Jan 2025 16:28:24 -0600 Subject: [PATCH 04/13] review: remove backend types from __init__.py __all__ list --- src/stapi_fastapi/backends/__init__.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/stapi_fastapi/backends/__init__.py b/src/stapi_fastapi/backends/__init__.py index b2fb899..e69de29 100644 --- a/src/stapi_fastapi/backends/__init__.py +++ b/src/stapi_fastapi/backends/__init__.py @@ -1,10 +0,0 @@ -from .product_backend import CreateOrder, SearchOpportunities -from .root_backend import GetOrder, GetOrders, GetOrderStatuses - -__all__ = [ - "CreateOrder", - "GetOrder", - "GetOrders", - "GetOrderStatuses", - "SearchOpportunities", -] From 80ca7b0658ae52a09fb52e517dd62a1e14b699d9 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Mon, 27 Jan 2025 16:56:02 -0600 Subject: [PATCH 05/13] review: enable overriding of orders_db and opportunities in stapi_client fixture --- tests/conftest.py | 31 ++++++++++++++++++------------- tests/test_opportunity.py | 5 +---- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b824dfb..1b6ea69 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -62,19 +62,25 @@ def mock_product_test_spotlight( ) -@asynccontextmanager -async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: - try: - yield { - "_orders_db": InMemoryOrderDB(), - "_opportunities": [], - } - finally: - pass - - @pytest.fixture -def stapi_client(mock_product_test_spotlight, base_url: str) -> Iterator[TestClient]: +def stapi_client( + mock_product_test_spotlight, + base_url: str, + orders_db: InMemoryOrderDB | None = None, + opportunities: list[Opportunity] | None = None, +) -> Iterator[TestClient]: + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: + try: + yield { + "_orders_db": orders_db if orders_db is not None else InMemoryOrderDB(), + "_opportunities": opportunities + if opportunities is not None + else mock_test_spotlight_opportunities(), + } + finally: + pass + root_router = RootRouter( get_orders=mock_get_orders, get_order=mock_get_order, @@ -131,7 +137,6 @@ def mock_provider() -> Provider: ) -@pytest.fixture 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 diff --git a/tests/test_opportunity.py b/tests/test_opportunity.py index 84b31cc..02e53e8 100644 --- a/tests/test_opportunity.py +++ b/tests/test_opportunity.py @@ -3,7 +3,7 @@ import pytest from fastapi.testclient import TestClient -from stapi_fastapi.models.opportunity import Opportunity, OpportunityCollection +from stapi_fastapi.models.opportunity import OpportunityCollection from .test_datetime_interval import rfc3339_strftime @@ -11,12 +11,9 @@ @pytest.mark.parametrize("product_id", ["test-spotlight"]) def test_search_opportunities_response( product_id: str, - mock_test_spotlight_opportunities: list[Opportunity], stapi_client: TestClient, assert_link, ) -> None: - stapi_client.app_state["_opportunities"] = mock_test_spotlight_opportunities - now = datetime.now(UTC) start = now end = start + timedelta(days=5) From f013ef324f48e6b329d055c869e1075ac3d2d2c0 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Mon, 27 Jan 2025 17:34:21 -0600 Subject: [PATCH 06/13] ci: fix incorrect python version being used in github workflow --- .github/workflows/pr.yml | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4f08479..5615e74 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -9,23 +9,28 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.12"] + python-version: ["3.12", "3.13"] steps: - uses: actions/checkout@v4 - - run: pipx install poetry==1.7.1 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - cache: poetry - - name: Install dependencies - run: poetry install --with=dev - - name: Lint code - if: ${{ matrix.python-version == 3.12 }} + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: | + ~/.cache/pip + .venv + key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }} + restore-keys: | + ${{ runner.os }}-pip-${{ matrix.python-version }}- + ${{ runner.os }}-pip- + - name: Install + run: | + python -m pip install poetry==1.7.1 + poetry install --with=dev + - name: Lint run: | - python -m pip install pre-commit - pre-commit run --all-files - - name: Type-check code - if: ${{ matrix.python-version == 3.12 }} - run: poetry run mypy src - - name: Run tests - run: poetry run nox + poetry run pre-commit run --all-files + - name: Test + run: poetry run pytest From 92bfb3f2c870ec7974eb2985aeefaf97a95b6e4c Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Mon, 27 Jan 2025 20:10:29 -0600 Subject: [PATCH 07/13] fix: allow python 3.12.0 and above --- noxfile.py | 2 +- poetry.lock | 4 ++-- pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/noxfile.py b/noxfile.py index 0b011a0..62ff8d1 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,7 +1,7 @@ import nox -@nox.session(python=["3.12"]) +@nox.session(python=["3.12", "3.13"]) def tests(session): session.run("poetry", "install", external=True) session.run("poetry", "run", "pytest", external=True) diff --git a/poetry.lock b/poetry.lock index 0c692dd..5ccaf8d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1182,5 +1182,5 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" -python-versions = "3.12.*" -content-hash = "d3c6efb6d8f4a49b8098ce6aa8a383ede2961b5632e66b6e56f5c86ed2edf8c9" +python-versions = "^3.12.0" +content-hash = "3b108a1181ac360d0e2ade6d25ee881ed0274f5033040a86fbd0cd8676ec813d" diff --git a/pyproject.toml b/pyproject.toml index 32ac640..62ac7d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.md" packages = [{include = "stapi_fastapi", from="src"}] [tool.poetry.dependencies] -python = "3.12.*" +python = "^3.12.0" fastapi = "^0.115.0" pydantic = "^2.10.1" geojson-pydantic = "^1.1.1" From 734180c2c5393f8cabd59dc7da2f6e1112324ae4 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Tue, 28 Jan 2025 05:29:41 -0600 Subject: [PATCH 08/13] review: correct response to exposing types in public API --- src/stapi_fastapi/__init__.py | 7 ------- src/stapi_fastapi/backends/__init__.py | 10 ++++++++++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/stapi_fastapi/__init__.py b/src/stapi_fastapi/__init__.py index a18092b..44b98ae 100644 --- a/src/stapi_fastapi/__init__.py +++ b/src/stapi_fastapi/__init__.py @@ -1,5 +1,3 @@ -from .backends.product_backend import CreateOrder, SearchOpportunities -from .backends.root_backend import GetOrder, GetOrders, GetOrderStatuses from .models import ( Link, OpportunityProperties, @@ -10,10 +8,6 @@ from .routers import ProductRouter, RootRouter __all__ = [ - "CreateOrder", - "GetOrder", - "GetOrders", - "GetOrderStatuses", "Link", "OpportunityProperties", "Product", @@ -21,5 +15,4 @@ "Provider", "ProviderRole", "RootRouter", - "SearchOpportunities", ] diff --git a/src/stapi_fastapi/backends/__init__.py b/src/stapi_fastapi/backends/__init__.py index e69de29..b2fb899 100644 --- a/src/stapi_fastapi/backends/__init__.py +++ b/src/stapi_fastapi/backends/__init__.py @@ -0,0 +1,10 @@ +from .product_backend import CreateOrder, SearchOpportunities +from .root_backend import GetOrder, GetOrders, GetOrderStatuses + +__all__ = [ + "CreateOrder", + "GetOrder", + "GetOrders", + "GetOrderStatuses", + "SearchOpportunities", +] From c6ca74b3d96aeb2f1fa931c6ec4462e447c4e6ba Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Tue, 28 Jan 2025 06:24:30 -0600 Subject: [PATCH 09/13] review: more test cleanup --- tests/conftest.py | 55 ++++++++--------------------------------------- 1 file changed, 9 insertions(+), 46 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 1b6ea69..c05b08b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,22 +16,17 @@ ) from stapi_fastapi.models.product import ( Product, - Provider, - ProviderRole, ) from stapi_fastapi.routers.root_router import RootRouter from .application import ( InMemoryOrderDB, MyOpportunityProperties, - MyOrderParameters, - MyProductConstraints, OffNadirRange, - mock_create_order, mock_get_order, mock_get_order_statuses, mock_get_orders, - mock_search_opportunities, + product, ) from .shared import find_link @@ -42,41 +37,23 @@ def base_url() -> Iterator[str]: @pytest.fixture -def mock_product_test_spotlight( - mock_provider: Provider, -) -> Product: +def mock_product() -> Product: """Fixture for a mock Test Spotlight product.""" - return Product( - id="test-spotlight", - title="Test Spotlight Product", - description="Test product for test spotlight", - license="CC-BY-4.0", - keywords=["test", "satellite"], - providers=[mock_provider], - links=[], - constraints=MyProductConstraints, - opportunity_properties=MyOpportunityProperties, - order_parameters=MyOrderParameters, - create_order=mock_create_order, - search_opportunities=mock_search_opportunities, - ) + return product @pytest.fixture def stapi_client( - mock_product_test_spotlight, + mock_product, base_url: str, - orders_db: InMemoryOrderDB | None = None, - opportunities: list[Opportunity] | None = None, + mock_opportunities: list[Opportunity], ) -> Iterator[TestClient]: @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: try: yield { - "_orders_db": orders_db if orders_db is not None else InMemoryOrderDB(), - "_opportunities": opportunities - if opportunities is not None - else mock_test_spotlight_opportunities(), + "_orders_db": InMemoryOrderDB(), + "_opportunities": mock_opportunities, } finally: pass @@ -86,7 +63,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: get_order=mock_get_order, get_order_statuses=mock_get_order_statuses, ) - root_router.add_product(mock_product_test_spotlight) + root_router.add_product(mock_product) app = FastAPI(lifespan=lifespan) app.include_router(root_router, prefix="") @@ -123,21 +100,7 @@ def _assert_link( @pytest.fixture -def products(mock_product_test_spotlight: Product) -> list[Product]: - return [mock_product_test_spotlight] - - -@pytest.fixture -def mock_provider() -> Provider: - return 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 - ) - - -def mock_test_spotlight_opportunities() -> list[Opportunity]: +def mock_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 8c96be39033450c9d194812ca80403ab83767fab Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Mon, 3 Feb 2025 16:13:26 -0600 Subject: [PATCH 10/13] fix: clean up some merge detritus --- src/stapi_fastapi/backends/root_backend.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/stapi_fastapi/backends/root_backend.py b/src/stapi_fastapi/backends/root_backend.py index 9f688bf..f072a93 100644 --- a/src/stapi_fastapi/backends/root_backend.py +++ b/src/stapi_fastapi/backends/root_backend.py @@ -44,11 +44,6 @@ - Returning returns.result.Failure[Exception] will result in a 500. """ -# async def get_order_statuses( -# self, order_id: str, request: Request, next: str | None, limit: int -# ) -> ResultE[tuple[list[T], Maybe[str]]]: -# """ -# Get statuses for order with `order_id` and return pagination token if applicable T = TypeVar("T", bound=OrderStatus) From db236c5db701faf0d52bb484bc49aabdd3d9a59a Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Mon, 3 Feb 2025 16:19:36 -0600 Subject: [PATCH 11/13] refactor (tests): move backend functions to ... backends.py --- tests/application.py | 138 +++---------------------------------------- tests/backends.py | 137 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 129 deletions(-) create mode 100644 tests/backends.py diff --git a/tests/application.py b/tests/application.py index 5512637..f5ee7a6 100644 --- a/tests/application.py +++ b/tests/application.py @@ -1,38 +1,35 @@ from collections import defaultdict from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from datetime import datetime, timezone from typing import Any, Literal, Self -from uuid import uuid4 -from fastapi import FastAPI, Request +from fastapi import FastAPI from pydantic import BaseModel, Field, model_validator -from returns.maybe import Maybe, Nothing, Some -from returns.result import Failure, ResultE, Success from stapi_fastapi.models.conformance import CORE from stapi_fastapi.models.opportunity import ( - Opportunity, OpportunityProperties, - OpportunityRequest, ) from stapi_fastapi.models.order import ( Order, OrderParameters, - OrderPayload, - OrderProperties, - OrderSearchParameters, OrderStatus, - OrderStatusCode, ) from stapi_fastapi.models.product import ( Product, Provider, ProviderRole, ) -from stapi_fastapi.routers.product_router import ProductRouter from stapi_fastapi.routers.root_router import RootRouter +from .backends import ( + mock_create_order, + mock_get_order, + mock_get_order_statuses, + mock_get_orders, + mock_search_opportunities, +) + class InMemoryOrderDB: def __init__(self) -> None: @@ -40,123 +37,6 @@ def __init__(self) -> None: self._statuses: dict[str, list[OrderStatus]] = defaultdict(list) -async def mock_get_orders( - request: Request, next: str | None, limit: int -) -> ResultE[tuple[list[Order], Maybe[str]]]: - """ - Return orders from backend. Handle pagination/limit if applicable - """ - try: - start = 0 - limit = min(limit, 100) - order_ids = [*request.state._orders_db._orders.keys()] - - if next: - start = order_ids.index(next) - end = start + limit - ids = order_ids[start:end] - orders = [request.state._orders_db._orders[order_id] for order_id in ids] - - if end > 0 and end < len(order_ids): - return Success( - (orders, Some(request.state._orders_db._orders[order_ids[end]].id)) - ) - return Success((orders, Nothing)) - except Exception as e: - return Failure(e) - - -async def mock_get_order(order_id: str, request: Request) -> ResultE[Maybe[Order]]: - """ - Show details for order with `order_id`. - """ - - return Success(Maybe.from_optional(request.state._orders_db._orders.get(order_id))) - - -async def mock_get_order_statuses( - order_id: str, request: Request, next: str | None, limit: int -) -> ResultE[tuple[list[OrderStatus], Maybe[str]]]: - try: - start = 0 - limit = min(limit, 100) - statuses = request.state._orders_db._statuses[order_id] - - if next: - start = int(next) - end = start + limit - stati = statuses[start:end] - - if end > 0 and end < len(statuses): - return Success((stati, Some(str(end)))) - return Success((stati, Nothing)) - except Exception as e: - return Failure(e) - - -async def mock_search_opportunities( - product_router: ProductRouter, - search: OpportunityRequest, - request: Request, - next: str | None, - limit: int, -) -> ResultE[tuple[list[Opportunity], Maybe[str]]]: - try: - start = 0 - limit = min(limit, 100) - if next: - start = int(next) - end = start + limit - opportunities = [ - o.model_copy(update=search.model_dump()) - for o in request.state._opportunities[start:end] - ] - if end > 0 and end < len(request.state._opportunities): - return Success((opportunities, Some(str(end)))) - return Success((opportunities, Nothing)) - except Exception as e: - return Failure(e) - - -async def mock_create_order( - product_router: ProductRouter, payload: OrderPayload, request: Request -) -> ResultE[Order]: - """ - Create a new order. - """ - try: - status = OrderStatus( - timestamp=datetime.now(timezone.utc), - status_code=OrderStatusCode.received, - ) - order = Order( - id=str(uuid4()), - geometry=payload.geometry, - properties=OrderProperties( - product_id=product_router.product.id, - created=datetime.now(timezone.utc), - status=status, - search_parameters=OrderSearchParameters( - geometry=payload.geometry, - datetime=payload.datetime, - filter=payload.filter, - ), - order_parameters=payload.order_parameters.model_dump(), - opportunity_properties={ - "datetime": "2024-01-29T12:00:00Z/2024-01-30T12:00:00Z", - "off_nadir": 10, - }, - ), - links=[], - ) - - request.state._orders_db._orders[order.id] = order - request.state._orders_db._statuses[order.id].insert(0, status) - return Success(order) - except Exception as e: - return Failure(e) - - class MyProductConstraints(BaseModel): off_nadir: int diff --git a/tests/backends.py b/tests/backends.py new file mode 100644 index 0000000..0810bf7 --- /dev/null +++ b/tests/backends.py @@ -0,0 +1,137 @@ +from datetime import datetime, timezone +from uuid import uuid4 + +from fastapi import Request +from returns.maybe import Maybe, Nothing, Some +from returns.result import Failure, ResultE, Success + +from stapi_fastapi.models.opportunity import ( + Opportunity, + OpportunityRequest, +) +from stapi_fastapi.models.order import ( + Order, + OrderPayload, + OrderProperties, + OrderSearchParameters, + OrderStatus, + OrderStatusCode, +) +from stapi_fastapi.routers.product_router import ProductRouter + + +async def mock_get_orders( + request: Request, next: str | None, limit: int +) -> ResultE[tuple[list[Order], Maybe[str]]]: + """ + Return orders from backend. Handle pagination/limit if applicable + """ + try: + start = 0 + limit = min(limit, 100) + order_ids = [*request.state._orders_db._orders.keys()] + + if next: + start = order_ids.index(next) + end = start + limit + ids = order_ids[start:end] + orders = [request.state._orders_db._orders[order_id] for order_id in ids] + + if end > 0 and end < len(order_ids): + return Success( + (orders, Some(request.state._orders_db._orders[order_ids[end]].id)) + ) + return Success((orders, Nothing)) + except Exception as e: + return Failure(e) + + +async def mock_get_order(order_id: str, request: Request) -> ResultE[Maybe[Order]]: + """ + Show details for order with `order_id`. + """ + + return Success(Maybe.from_optional(request.state._orders_db._orders.get(order_id))) + + +async def mock_get_order_statuses( + order_id: str, request: Request, next: str | None, limit: int +) -> ResultE[tuple[list[OrderStatus], Maybe[str]]]: + try: + start = 0 + limit = min(limit, 100) + statuses = request.state._orders_db._statuses[order_id] + + if next: + start = int(next) + end = start + limit + stati = statuses[start:end] + + if end > 0 and end < len(statuses): + return Success((stati, Some(str(end)))) + return Success((stati, Nothing)) + except Exception as e: + return Failure(e) + + +async def mock_search_opportunities( + product_router: ProductRouter, + search: OpportunityRequest, + request: Request, + next: str | None, + limit: int, +) -> ResultE[tuple[list[Opportunity], Maybe[str]]]: + try: + start = 0 + limit = min(limit, 100) + if next: + start = int(next) + end = start + limit + opportunities = [ + o.model_copy(update=search.model_dump()) + for o in request.state._opportunities[start:end] + ] + if end > 0 and end < len(request.state._opportunities): + return Success((opportunities, Some(str(end)))) + return Success((opportunities, Nothing)) + except Exception as e: + return Failure(e) + + +async def mock_create_order( + product_router: ProductRouter, payload: OrderPayload, request: Request +) -> ResultE[Order]: + """ + Create a new order. + """ + try: + status = OrderStatus( + timestamp=datetime.now(timezone.utc), + status_code=OrderStatusCode.received, + ) + order = Order( + id=str(uuid4()), + geometry=payload.geometry, + properties=OrderProperties( + product_id=product_router.product.id, + created=datetime.now(timezone.utc), + status=status, + search_parameters=OrderSearchParameters( + geometry=payload.geometry, + datetime=payload.datetime, + filter=payload.filter, + ), + order_parameters=payload.order_parameters.model_dump(), + opportunity_properties={ + "datetime": "2024-01-29T12:00:00Z/2024-01-30T12:00:00Z", + "off_nadir": 10, + }, + ), + links=[], + ) + + request.state._orders_db._orders[order.id] = order + request.state._orders_db._statuses[order.id].insert(0, status) + return Success(order) + except Exception as e: + return Failure(e) From 037ce4637d4173a5f8cf541bf852b01c705f2996 Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Mon, 3 Feb 2025 18:14:21 -0600 Subject: [PATCH 12/13] refactor (tests): move shared test code to ... shared.py --- tests/application.py | 81 ++-------------- tests/conftest.py | 142 +++------------------------- tests/shared.py | 194 +++++++++++++++++++++++++++++++++++++- tests/test_opportunity.py | 15 +-- tests/test_order.py | 4 +- tests/test_product.py | 3 +- 6 files changed, 219 insertions(+), 220 deletions(-) diff --git a/tests/application.py b/tests/application.py index f5ee7a6..b9442c7 100644 --- a/tests/application.py +++ b/tests/application.py @@ -1,88 +1,21 @@ -from collections import defaultdict +import os +import sys from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from typing import Any, Literal, Self +from typing import Any from fastapi import FastAPI -from pydantic import BaseModel, Field, model_validator + +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from stapi_fastapi.models.conformance import CORE -from stapi_fastapi.models.opportunity import ( - OpportunityProperties, -) -from stapi_fastapi.models.order import ( - Order, - OrderParameters, - OrderStatus, -) -from stapi_fastapi.models.product import ( - Product, - Provider, - ProviderRole, -) from stapi_fastapi.routers.root_router import RootRouter - -from .backends import ( - mock_create_order, +from tests.backends import ( mock_get_order, mock_get_order_statuses, mock_get_orders, - mock_search_opportunities, -) - - -class InMemoryOrderDB: - def __init__(self) -> None: - self._orders: dict[str, Order] = {} - self._statuses: dict[str, list[OrderStatus]] = defaultdict(list) - - -class MyProductConstraints(BaseModel): - off_nadir: int - - -class OffNadirRange(BaseModel): - minimum: int = Field(ge=0, le=45) - maximum: int = Field(ge=0, le=45) - - @model_validator(mode="after") - def validate_range(self) -> Self: - if self.minimum > self.maximum: - raise ValueError("range minimum cannot be greater than maximum") - return self - - -class MyOpportunityProperties(OpportunityProperties): - off_nadir: OffNadirRange - vehicle_id: list[Literal[1, 2, 5, 7, 8]] - platform: Literal["platform_id"] - - -class MyOrderParameters(OrderParameters): - s3_path: str | None = None - - -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 -) - -mock_product_test_spotlight = 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=[], - create_order=mock_create_order, - search_opportunities=mock_search_opportunities, - constraints=MyProductConstraints, - opportunity_properties=MyOpportunityProperties, - order_parameters=MyOrderParameters, ) +from tests.shared import InMemoryOrderDB, mock_product_test_spotlight @asynccontextmanager diff --git a/tests/conftest.py b/tests/conftest.py index 423ecea..c060495 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,17 +1,11 @@ from collections.abc import AsyncIterator, Iterator from contextlib import asynccontextmanager -from datetime import datetime, timedelta, timezone from typing import Any, Callable from urllib.parse import urljoin -from uuid import uuid4 import pytest -from fastapi import FastAPI, status +from fastapi import FastAPI from fastapi.testclient import TestClient -from geojson_pydantic import Point -from geojson_pydantic.types import Position2D -from httpx import Response -from pytest import fail from stapi_fastapi.models.opportunity import ( Opportunity, @@ -21,21 +15,18 @@ ) from stapi_fastapi.routers.root_router import RootRouter -from .application import ( - InMemoryOrderDB, - MyOpportunityProperties, - MyOrderParameters, - MyProductConstraints, - OffNadirRange, - mock_create_order, +from .backends import ( mock_get_order, mock_get_order_statuses, mock_get_orders, +) +from .shared import ( + InMemoryOrderDB, + create_mock_opportunity, + find_link, + mock_product_test_satellite_provider, mock_product_test_spotlight, - mock_search_opportunities, - provider, ) -from .shared import find_link @pytest.fixture(scope="session") @@ -43,27 +34,16 @@ def base_url() -> Iterator[str]: yield "http://stapiserver" -mock_product_test_satellite_provider = Product( - id="test-satellite-provider", - title="Satellite Product", - description="A product by a satellite provider", - license="CC-BY-4.0", - keywords=["test", "satellite", "provider"], - providers=[provider], - links=[], - create_order=mock_create_order, - search_opportunities=mock_search_opportunities, - constraints=MyProductConstraints, - opportunity_properties=MyOpportunityProperties, - order_parameters=MyOrderParameters, -) - - @pytest.fixture def mock_products() -> list[Product]: return [mock_product_test_spotlight, mock_product_test_satellite_provider] +@pytest.fixture +def mock_opportunities() -> list[Opportunity]: + return [create_mock_opportunity()] + + @pytest.fixture def stapi_client( mock_products: list[Product], @@ -134,99 +114,3 @@ def _assert_link( assert link["href"] == url_for(path) return _assert_link - - -@pytest.fixture -def mock_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) - - # Create a list of mock opportunities for the given product - return [ - Opportunity( - id=str(uuid4()), - type="Feature", - geometry=Point( - type="Point", - coordinates=Position2D(longitude=0.0, latitude=0.0), - ), - properties=MyOpportunityProperties( - product_id="xyz123", - datetime=(start, end), - off_nadir=OffNadirRange(minimum=20, maximum=22), - vehicle_id=[1], - platform="platform_id", - other_thing="abcd1234", - ), - ), - ] - - -def pagination_tester( - stapi_client: TestClient, - endpoint: str, - method: str, - limit: int, - target: str, - expected_returns: list, - body: dict | None = None, -) -> None: - retrieved = [] - - res = make_request(stapi_client, endpoint, method, body, None, limit) - assert res.status_code == status.HTTP_200_OK - resp_body = res.json() - - assert len(resp_body[target]) <= limit - retrieved.extend(resp_body[target]) - next_url = next((d["href"] for d in resp_body["links"] if d["rel"] == "next"), None) - - while next_url: - url = next_url - if method == "POST": - body = next( - (d["body"] for d in resp_body["links"] if d["rel"] == "next"), None - ) - - res = make_request(stapi_client, url, method, body, next_url, limit) - assert res.status_code == status.HTTP_200_OK - assert len(resp_body[target]) <= limit - resp_body = res.json() - retrieved.extend(resp_body[target]) - - # get url w/ query params for next call if exists, and POST body if necessary - if resp_body["links"]: - next_url = next( - (d["href"] for d in resp_body["links"] if d["rel"] == "next"), None - ) - else: - next_url = None - - assert len(retrieved) == len(expected_returns) - assert retrieved == expected_returns - - -def make_request( - stapi_client: TestClient, - endpoint: str, - method: str, - body: dict | None, - next_token: str | None, - limit: int, -) -> Response: - """request wrapper for pagination tests""" - - match method: - case "GET": - if next_token: # extract pagination token - next_token = next_token.split("next=")[1] - params = {"next": next_token, "limit": limit} - res = stapi_client.get(endpoint, params=params) - case "POST": - res = stapi_client.post(endpoint, json=body) - case _: - fail(f"method {method} not supported in make request") - - return res diff --git a/tests/shared.py b/tests/shared.py index 4a2aa32..45bff0d 100644 --- a/tests/shared.py +++ b/tests/shared.py @@ -1,7 +1,199 @@ -from typing import Any +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from typing import Any, Literal, Self +from uuid import uuid4 + +from fastapi import status +from fastapi.testclient import TestClient +from geojson_pydantic import Point +from geojson_pydantic.types import Position2D +from httpx import Response +from pydantic import BaseModel, Field, model_validator +from pytest import fail + +from stapi_fastapi.models.opportunity import ( + Opportunity, + OpportunityProperties, +) +from stapi_fastapi.models.order import ( + Order, + OrderParameters, + OrderStatus, +) +from stapi_fastapi.models.product import ( + Product, + Provider, + ProviderRole, +) + +from .backends import ( + mock_create_order, + mock_search_opportunities, +) type 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) + + +class InMemoryOrderDB: + def __init__(self) -> None: + self._orders: dict[str, Order] = {} + self._statuses: dict[str, list[OrderStatus]] = defaultdict(list) + + +class MyProductConstraints(BaseModel): + off_nadir: int + + +class OffNadirRange(BaseModel): + minimum: int = Field(ge=0, le=45) + maximum: int = Field(ge=0, le=45) + + @model_validator(mode="after") + def validate_range(self) -> Self: + if self.minimum > self.maximum: + raise ValueError("range minimum cannot be greater than maximum") + return self + + +class MyOpportunityProperties(OpportunityProperties): + off_nadir: OffNadirRange + vehicle_id: list[Literal[1, 2, 5, 7, 8]] + platform: Literal["platform_id"] + + +class MyOrderParameters(OrderParameters): + s3_path: str | None = None + + +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 +) + +mock_product_test_spotlight = 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=[], + create_order=mock_create_order, + search_opportunities=mock_search_opportunities, + constraints=MyProductConstraints, + opportunity_properties=MyOpportunityProperties, + order_parameters=MyOrderParameters, +) + +mock_product_test_satellite_provider = Product( + id="test-satellite-provider", + title="Satellite Product", + description="A product by a satellite provider", + license="CC-BY-4.0", + keywords=["test", "satellite", "provider"], + providers=[provider], + links=[], + create_order=mock_create_order, + search_opportunities=mock_search_opportunities, + constraints=MyProductConstraints, + opportunity_properties=MyOpportunityProperties, + order_parameters=MyOrderParameters, +) + + +def create_mock_opportunity() -> Opportunity: + now = datetime.now(timezone.utc) # Use timezone-aware datetime + start = now + end = start + timedelta(days=5) + + # Create a list of mock opportunities for the given product + return Opportunity( + id=str(uuid4()), + type="Feature", + geometry=Point( + type="Point", + coordinates=Position2D(longitude=0.0, latitude=0.0), + ), + properties=MyOpportunityProperties( + product_id="xyz123", + datetime=(start, end), + off_nadir=OffNadirRange(minimum=20, maximum=22), + vehicle_id=[1], + platform="platform_id", + other_thing="abcd1234", + ), + ) + + +def pagination_tester( + stapi_client: TestClient, + endpoint: str, + method: str, + limit: int, + target: str, + expected_returns: list, + body: dict | None = None, +) -> None: + retrieved = [] + + res = make_request(stapi_client, endpoint, method, body, None, limit) + assert res.status_code == status.HTTP_200_OK + resp_body = res.json() + + assert len(resp_body[target]) <= limit + retrieved.extend(resp_body[target]) + next_url = next((d["href"] for d in resp_body["links"] if d["rel"] == "next"), None) + + while next_url: + url = next_url + if method == "POST": + body = next( + (d["body"] for d in resp_body["links"] if d["rel"] == "next"), None + ) + + res = make_request(stapi_client, url, method, body, next_url, limit) + assert res.status_code == status.HTTP_200_OK + assert len(resp_body[target]) <= limit + resp_body = res.json() + retrieved.extend(resp_body[target]) + + # get url w/ query params for next call if exists, and POST body if necessary + if resp_body["links"]: + next_url = next( + (d["href"] for d in resp_body["links"] if d["rel"] == "next"), None + ) + else: + next_url = None + + assert len(retrieved) == len(expected_returns) + assert retrieved == expected_returns + + +def make_request( + stapi_client: TestClient, + endpoint: str, + method: str, + body: dict | None, + next_token: str | None, + limit: int, +) -> Response: + """request wrapper for pagination tests""" + + match method: + case "GET": + if next_token: # extract pagination token + next_token = next_token.split("next=")[1] + params = {"next": next_token, "limit": limit} + res = stapi_client.get(endpoint, params=params) + case "POST": + res = stapi_client.post(endpoint, json=body) + case _: + fail(f"method {method} not supported in make request") + + return res diff --git a/tests/test_opportunity.py b/tests/test_opportunity.py index 3991495..389d47d 100644 --- a/tests/test_opportunity.py +++ b/tests/test_opportunity.py @@ -1,15 +1,13 @@ from datetime import UTC, datetime, timedelta -from typing import List import pytest from fastapi.testclient import TestClient from stapi_fastapi.models.opportunity import ( - Opportunity, OpportunityCollection, ) -from tests.conftest import pagination_tester +from .shared import create_mock_opportunity, pagination_tester from .test_datetime_interval import rfc3339_strftime @@ -61,21 +59,14 @@ def test_search_opportunities_response( assert_link(f"POST {url}", body, "create-order", f"/products/{product_id}/orders") -@pytest.fixture -def mock_pagination_opportunities( - mock_opportunities, -) -> list[Opportunity]: - return [opp for opp in mock_opportunities for __ in range(0, 3)] - - @pytest.mark.parametrize("limit", [0, 1, 2, 4]) def test_search_opportunities_pagination( limit: int, stapi_client: TestClient, - mock_pagination_opportunities: List[Opportunity], ) -> None: - product_id = "test-spotlight" + mock_pagination_opportunities = [create_mock_opportunity() for __ in range(3)] stapi_client.app_state["_opportunities"] = mock_pagination_opportunities + product_id = "test-spotlight" expected_returns = [] if limit != 0: expected_returns = [ diff --git a/tests/test_order.py b/tests/test_order.py index 54ff205..3173489 100644 --- a/tests/test_order.py +++ b/tests/test_order.py @@ -9,10 +9,8 @@ from httpx import Response from stapi_fastapi.models.order import Order, OrderPayload, OrderStatus, OrderStatusCode -from tests.conftest import pagination_tester -from .application import MyOrderParameters -from .shared import find_link +from .shared import MyOrderParameters, find_link, pagination_tester NOW = datetime.now(UTC) START = NOW diff --git a/tests/test_product.py b/tests/test_product.py index db70b70..efca972 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -3,7 +3,8 @@ from fastapi.testclient import TestClient from stapi_fastapi.models.product import Product -from tests.conftest import pagination_tester + +from .shared import pagination_tester def test_products_response(stapi_client: TestClient): From d0594c0de6a764b234c8ae8d6d2754c04c8e437a Mon Sep 17 00:00:00 2001 From: pjhartzell Date: Mon, 3 Feb 2025 18:50:45 -0600 Subject: [PATCH 13/13] docs: add missing changelog entry for token-based pagination --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba2833b..8167052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,20 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- Added token-based pagination to `GET /orders`, `GET /products`, + `GET /orders/{order_id}/statuses`, and `POST /products/{product_id}/opportunities`. + ### Changed - Replaced the root and product backend Protocol classes with Callable type aliases to enable future changes to make product opportunity searching, product ordering, and/or asynchronous (stateful) product opportunity searching optional. +- Backend methods that support pagination now return tuples to include the pagination + token. +- Moved `OrderCollection` construction from the root backend to the `RootRouter` + `get_orders` method. ## [v0.5.0] - 2025-01-08