diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 15e0afb..df47d12 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -24,7 +24,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.x" + python-version: "3.12.x" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/README.md b/README.md index e445061..38c278a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,18 @@ guaranteed to be not correct. STAPI FastAPI provides an `fastapi.APIRouter` which must be included in `fastapi.FastAPI` instance. +### Pagination + +4 endpoints currently offer pagination: +`GET`: `'/orders`, `/products`, `/orders/{order_id}/statuses` +`POST`: `/opportunities`. + +Pagination is token based and follows recommendations in the [STAC API pagination]. Limit and token are passed in as query params for `GET` endpoints, and via the body aas separte key/value pairs for `POST` requests. + +If pagination is available and more records remain the response object will contain a `next` link object that can be used to get the next page of results. No `next` `Link` returned indicates there are no further records available. + +`limit` defaults to 10 and maxes at 100. + ## ADRs @@ -59,3 +71,4 @@ With the `uvicorn` defaults the app should be accessible at [STAPI spec]: https://github.com/stapi-spec/stapi-spec [poetry]: https://python-poetry.org/ +[STAC API pagination]: https://github.com/radiantearth/stac-api-spec/blob/release/v1.0.0/item-search/examples.md#paging-examples diff --git a/poetry.lock b/poetry.lock index 0c692dd..59bfa29 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. [[package]] name = "annotated-types" @@ -1183,4 +1183,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "3.12.*" -content-hash = "d3c6efb6d8f4a49b8098ce6aa8a383ede2961b5632e66b6e56f5c86ed2edf8c9" +content-hash = "dbd480b7af18b692724040c7f01a5e302a61564bda31876482f4f0917b3244de" diff --git a/src/stapi_fastapi/backends/product_backend.py b/src/stapi_fastapi/backends/product_backend.py index 0172500..b55104e 100644 --- a/src/stapi_fastapi/backends/product_backend.py +++ b/src/stapi_fastapi/backends/product_backend.py @@ -3,6 +3,7 @@ from typing import Protocol from fastapi import Request +from returns.maybe import Maybe from returns.result import ResultE from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest @@ -16,9 +17,11 @@ async def search_opportunities( product_router: ProductRouter, search: OpportunityRequest, request: Request, - ) -> ResultE[list[Opportunity]]: + next: str | None, + limit: int, + ) -> ResultE[tuple[list[Opportunity], Maybe[str]]]: """ - Search for ordering opportunities for the given search parameters. + Search for ordering opportunities for the given search parameters and return pagination token if applicable. Backends must validate search constraints and return `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..fb3d0e6 100644 --- a/src/stapi_fastapi/backends/root_backend.py +++ b/src/stapi_fastapi/backends/root_backend.py @@ -6,15 +6,16 @@ from stapi_fastapi.models.order import ( Order, - OrderCollection, OrderStatus, ) class RootBackend[T: OrderStatus](Protocol): # pragma: nocover - async def get_orders(self, request: Request) -> ResultE[OrderCollection]: + async def get_orders( + self, request: Request, next: str | None, limit: int + ) -> ResultE[tuple[list[Order], Maybe[str]]]: """ - Return a list of existing orders. + Return a list of existing orders and pagination token if applicable. """ ... @@ -24,18 +25,18 @@ async def get_order(self, order_id: str, request: Request) -> ResultE[Maybe[Orde Should return returns.results.Success[Order] if order is found. - Should return returns.results.Failure[returns.maybe.Nothing] if the order is - not found or if access is denied. + Should return returns.results.Failure[returns.maybe.Nothing] if the + order is not found or if access is denied. A Failure[Exception] will result in a 500. """ ... async def get_order_statuses( - self, order_id: str, request: Request - ) -> ResultE[list[T]]: + self, order_id: str, request: Request, next: str | None, limit: int + ) -> ResultE[tuple[list[T], Maybe[str]]]: """ - Get statuses for order with `order_id`. + Get statuses for order with `order_id` and return pagination token if applicable Should return returns.results.Success[list[OrderStatus]] if order is found. diff --git a/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py index e98738c..e3af43a 100644 --- a/src/stapi_fastapi/routers/product_router.py +++ b/src/stapi_fastapi/routers/product_router.py @@ -2,10 +2,11 @@ import logging import traceback -from typing import TYPE_CHECKING, Self +from typing import TYPE_CHECKING, Annotated, Self -from fastapi import APIRouter, HTTPException, Request, Response, status +from fastapi import APIRouter, Body, HTTPException, Request, Response, status from geojson_pydantic.geometries import Geometry +from returns.maybe import Some from returns.result import Failure, Success from stapi_fastapi.constants import TYPE_JSON @@ -161,27 +162,29 @@ def get_product(self, request: Request) -> Product: ) async def search_opportunities( - self, search: OpportunityRequest, request: Request + self, + search: OpportunityRequest, + request: Request, + next: Annotated[str | None, Body()] = None, + limit: Annotated[int, Body()] = 10, ) -> OpportunityCollection: """ Explore the opportunities available for a particular set of constraints """ - match await self.product.backend.search_opportunities(self, search, request): - case Success(features): - return OpportunityCollection( - features=features, - links=[ - Link( - href=str( - request.url_for( - f"{self.root_router.name}:{self.product.id}:create-order", - ), - ), - rel="create-order", - type=TYPE_JSON, - ), - ], - ) + links: list[Link] = [] + match await self.product.backend.search_opportunities( + self, search, request, next, limit + ): + case Success((features, Some(pagination_token))): + links.append(self.order_link(request)) + body = { + "search": search.model_dump(mode="json"), + "next": pagination_token, + "limit": limit, + } + links.append(self.pagination_link(request, body)) + case Success((features, Nothing)): # noqa: F841 + links.append(self.order_link(request)) case Failure(e) if isinstance(e, ConstraintsException): raise e case Failure(e): @@ -195,6 +198,7 @@ async def search_opportunities( ) case x: raise AssertionError(f"Expected code to be unreachable {x}") + return OpportunityCollection(features=features, links=links) def get_product_constraints(self: Self) -> JsonSchemaModel: """ @@ -237,3 +241,24 @@ async def create_order( ) case x: raise AssertionError(f"Expected code to be unreachable {x}") + + def order_link(self, request: Request): + return Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:create-order", + ), + ), + rel="create-order", + type=TYPE_JSON, + method="POST", + ) + + def pagination_link(self, request: Request, body: dict[str, str | dict]): + return Link( + href=str(request.url.remove_query_params(keys=["next", "limit"])), + rel="next", + type=TYPE_JSON, + method="POST", + body=body, + ) diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index fa77604..34577c1 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -42,6 +42,7 @@ def __init__( self.conformances = conformances self.openapi_endpoint_name = openapi_endpoint_name self.docs_endpoint_name = docs_endpoint_name + self.product_ids: list[str] = [] # 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 @@ -140,34 +141,52 @@ def get_root(self, request: Request) -> RootResponse: def get_conformance(self, request: Request) -> Conformance: return Conformance(conforms_to=self.conformances) - def get_products(self, request: Request) -> ProductsCollection: + def get_products( + self, request: Request, next: str | None = None, limit: int = 10 + ) -> ProductsCollection: + start = 0 + limit = min(limit, 100) + try: + if next: + start = self.product_ids.index(next) + except ValueError: + logging.exception("An error occurred while retrieving products") + raise NotFoundException( + detail="Error finding pagination token for products" + ) from None + end = start + limit + ids = self.product_ids[start:end] + links = [ + Link( + href=str(request.url_for(f"{self.name}:list-products")), + rel="self", + type=TYPE_JSON, + ), + ] + if end > 0 and end < len(self.product_ids): + links.append(self.pagination_link(request, self.product_ids[end])) return ProductsCollection( - products=[pr.get_product(request) for pr in self.product_routers.values()], - links=[ - Link( - href=str(request.url_for(f"{self.name}:list-products")), - rel="self", - type=TYPE_JSON, - ) + products=[ + self.product_routers[product_id].get_product(request) + for product_id in ids ], + links=links, ) - async def get_orders(self, request: Request) -> OrderCollection: - match await self.backend.get_orders(request): - case Success(orders): + async def get_orders( + self, request: Request, next: str | None = None, limit: int = 10 + ) -> OrderCollection: + links: list[Link] = [] + match await self.backend.get_orders(request, next, limit): + case Success((orders, Some(pagination_token))): for order in orders: - order.links.append( - Link( - href=str( - request.url_for( - f"{self.name}:get-order", order_id=order.id - ) - ), - rel="self", - type=TYPE_JSON, - ) - ) - return orders + order.links.append(self.order_link(request, order)) + links.append(self.pagination_link(request, pagination_token)) + case Success((orders, Nothing)): # noqa: F841 + for order in orders: + order.links.append(self.order_link(request, order)) + case Failure(ValueError()): + raise NotFoundException(detail="Error finding pagination token") case Failure(e): logger.error( "An error occurred while retrieving orders: %s", @@ -179,6 +198,7 @@ async def get_orders(self, request: Request) -> OrderCollection: ) case _: raise AssertionError("Expected code to be unreachable") + return OrderCollection(features=orders, links=links) async def get_order(self: Self, order_id: str, request: Request) -> Order: """ @@ -204,25 +224,21 @@ async def get_order(self: Self, order_id: str, request: Request) -> Order: raise AssertionError("Expected code to be unreachable") async def get_order_statuses( - self: Self, order_id: str, request: Request + self: Self, + order_id: str, + request: Request, + next: str | None = None, + limit: int = 10, ) -> OrderStatuses: - match await self.backend.get_order_statuses(order_id, request): - case Success(statuses): - return OrderStatuses( - statuses=statuses, - links=[ - Link( - href=str( - request.url_for( - f"{self.name}:list-order-statuses", - order_id=order_id, - ) - ), - rel="self", - type=TYPE_JSON, - ) - ], - ) + links: list[Link] = [] + match await self.backend.get_order_statuses(order_id, request, next, limit): + case Success((statuses, Some(pagination_token))): + links.append(self.order_statuses_link(request, order_id)) + links.append(self.pagination_link(request, pagination_token)) + case Success((statuses, Nothing)): # noqa: F841 + links.append(self.order_statuses_link(request, order_id)) + case Failure(KeyError()): + raise NotFoundException("Error finding pagination token") case Failure(e): logger.error( "An error occurred while retrieving order statuses: %s", @@ -234,12 +250,14 @@ async def get_order_statuses( ) case _: raise AssertionError("Expected code to be unreachable") + return OrderStatuses(statuses=statuses, links=links) def add_product(self: Self, product: Product, *args, **kwargs) -> None: # Give the include a prefix from the product router product_router = ProductRouter(product, self, *args, **kwargs) self.include_router(product_router, prefix=f"/products/{product.id}") self.product_routers[product.id] = product_router + self.product_ids = [*self.product_routers.keys()] def generate_order_href(self: Self, request: Request, order_id: str) -> URL: return request.url_for(f"{self.name}:get-order", order_id=order_id) @@ -264,3 +282,29 @@ def add_order_links(self, order: Order, request: Request): type=TYPE_JSON, ), ) + + def order_link(self, request: Request, order: Order): + return Link( + href=str(request.url_for(f"{self.name}:get-order", order_id=order.id)), + rel="self", + type=TYPE_JSON, + ) + + def order_statuses_link(self, request: Request, order_id: str): + return Link( + href=str( + request.url_for( + f"{self.name}:list-order-statuses", + order_id=order_id, + ) + ), + rel="self", + type=TYPE_JSON, + ) + + def pagination_link(self, request: Request, pagination_token: str): + return Link( + href=str(request.url.include_query_params(next=pagination_token)), + rel="next", + type=TYPE_JSON, + ) diff --git a/tests/application.py b/tests/application.py index 4329517..fa9294d 100644 --- a/tests/application.py +++ b/tests/application.py @@ -5,7 +5,7 @@ from fastapi import FastAPI, Request from pydantic import BaseModel, Field, model_validator -from returns.maybe import Maybe +from returns.maybe import Maybe, Nothing, Some from returns.result import Failure, ResultE, Success from stapi_fastapi.backends.product_backend import ProductBackend @@ -18,7 +18,6 @@ ) from stapi_fastapi.models.order import ( Order, - OrderCollection, OrderParameters, OrderPayload, OrderStatus, @@ -34,19 +33,39 @@ class InMemoryOrderDB: - _orders: dict[str, Order] = {} - _statuses: dict[str, list[OrderStatus]] = defaultdict(list) + def __init__(self) -> None: + self._orders: dict[str, Order] = {} + self._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]: + async def get_orders( + self, request: Request, next: str | None, limit: int + ) -> ResultE[tuple[list[Order], Maybe[str]]]: """ - Show all orders. + Return orders from backend. Handle pagination/limit if applicable """ - return Success(OrderCollection(features=list(self._orders_db._orders.values()))) + try: + start = 0 + limit = min(limit, 100) + order_ids = [*self._orders_db._orders.keys()] + + if next: + start = order_ids.index(next) + end = start + limit + ids = order_ids[start:end] + orders = [self._orders_db._orders[order_id] for order_id in ids] + + if end > 0 and end < len(order_ids): + return Success( + (orders, Some(self._orders_db._orders[order_ids[end]].id)) + ) + return Success((orders, Nothing)) + except Exception as e: + return Failure(e) async def get_order(self, order_id: str, request: Request) -> ResultE[Maybe[Order]]: """ @@ -56,9 +75,23 @@ async def get_order(self, order_id: str, request: Request) -> ResultE[Maybe[Orde 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]) + self, order_id: str, request: Request, next: str | None, limit: int + ) -> ResultE[tuple[list[OrderStatus], Maybe[str]]]: + try: + start = 0 + limit = min(limit, 100) + statuses = self._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) class MockProductBackend(ProductBackend): @@ -72,11 +105,22 @@ async def search_opportunities( product_router: ProductRouter, search: OpportunityRequest, request: Request, - ) -> ResultE[list[Opportunity]]: + next: str | None, + limit: int, + ) -> ResultE[tuple[list[Opportunity], Maybe[str]]]: try: - return Success( - [o.model_copy(update=search.model_dump()) for o in self._opportunities] - ) + 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 self._opportunities[start:end] + ] + if end > 0 and end < len(self._opportunities): + return Success((opportunities, Some(str(end)))) + return Success((opportunities, Nothing)) except Exception as e: return Failure(e) diff --git a/tests/conftest.py b/tests/conftest.py index 82eb841..cc2261c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,18 +1,13 @@ from collections.abc import Iterator -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 +from fastapi import FastAPI, status 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, -) from stapi_fastapi.models.product import ( Product, Provider, @@ -71,12 +66,46 @@ def mock_product_test_spotlight( ) +@pytest.fixture +def mock_product_test_satellite_provider( + product_backend: MockProductBackend, mock_provider: Provider +) -> Product: + """Fixture for a mock satellite provider product.""" + return 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=[mock_provider], + links=[], + constraints=MyProductConstraints, + opportunity_properties=MyOpportunityProperties, + order_parameters=MyOrderParameters, + backend=product_backend, + ) + + @pytest.fixture def stapi_client( - root_backend, mock_product_test_spotlight, base_url: str + root_backend, + mock_product_test_spotlight, + mock_product_test_satellite_provider, + base_url: str, ) -> Iterator[TestClient]: root_router = RootRouter(root_backend) root_router.add_product(mock_product_test_spotlight) + root_router.add_product(mock_product_test_satellite_provider) + app = FastAPI() + app.include_router(root_router, prefix="") + + with TestClient(app, base_url=f"{base_url}") as client: + yield client + + +@pytest.fixture +def empty_stapi_client(root_backend, base_url: str) -> Iterator[TestClient]: + root_router = RootRouter(root_backend) app = FastAPI() app.include_router(root_router, prefix="") @@ -127,29 +156,69 @@ 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 - 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={"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 29ba4d1..56fadef 100644 --- a/tests/test_opportunity.py +++ b/tests/test_opportunity.py @@ -1,15 +1,56 @@ -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime, timedelta, timezone from typing import List +from uuid import uuid4 import pytest from fastapi.testclient import TestClient +from geojson_pydantic import Point +from geojson_pydantic.types import Position2D -from stapi_fastapi.models.opportunity import Opportunity, OpportunityCollection +from stapi_fastapi.models.opportunity import ( + Opportunity, + OpportunityCollection, +) +from tests.application import MyOpportunityProperties +from tests.conftest import pagination_tester from .backends import MockProductBackend from .test_datetime_interval import rfc3339_strftime +@pytest.fixture +def mock_test_spotlight_opportunities() -> list[Opportunity]: + """Fixture to create mock data for Opportunities for `test-spotlight-1`.""" + start = datetime.now(timezone.utc) # Use timezone-aware datetime + 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={"minimum": 20, "maximum": 22}, + vehicle_id=[1], + platform="platform_id", + ), + ), + ] + + +@pytest.fixture +def mock_test_pagination_opportunities( + mock_test_spotlight_opportunities, +) -> list[Opportunity]: + return [opp for opp in mock_test_spotlight_opportunities for __ in range(0, 3)] + + @pytest.mark.parametrize("product_id", ["test-spotlight"]) def test_search_opportunities_response( product_id: str, @@ -21,35 +62,33 @@ def test_search_opportunities_response( product_backend._opportunities = mock_test_spotlight_opportunities now = datetime.now(UTC) - start = now - end = start + timedelta(days=5) + end = now + timedelta(days=5) format = "%Y-%m-%dT%H:%M:%S.%f%z" - start_string = rfc3339_strftime(start, format) + start_string = rfc3339_strftime(now, format) end_string = rfc3339_strftime(end, format) - # Prepare the request payload request_payload = { - "geometry": { - "type": "Point", - "coordinates": [0, 0], - }, - "datetime": f"{start_string}/{end_string}", - "filter": { - "op": "and", - "args": [ - {"op": ">", "args": [{"property": "off_nadir"}, 0]}, - {"op": "<", "args": [{"property": "off_nadir"}, 45]}, - ], + "search": { + "geometry": { + "type": "Point", + "coordinates": [0, 0], + }, + "datetime": f"{start_string}/{end_string}", + "filter": { + "op": "and", + "args": [ + {"op": ">", "args": [{"property": "off_nadir"}, 0]}, + {"op": "<", "args": [{"property": "off_nadir"}, 45]}, + ], + }, }, + "limit": 10, } - # 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) - # Validate response status and structure assert response.status_code == 200, f"Failed for product: {product_id}" body = response.json() @@ -59,3 +98,54 @@ def test_search_opportunities_response( pytest.fail("response is not an opportunity collection") assert_link(f"POST {url}", body, "create-order", f"/products/{product_id}/orders") + + +@pytest.mark.parametrize("limit", [0, 1, 2, 4]) +def test_search_opportunities_pagination( + limit: int, + stapi_client: TestClient, + product_backend: MockProductBackend, + mock_test_pagination_opportunities: List[Opportunity], +) -> None: + product_id = "test-spotlight" + product_backend._opportunities = mock_test_pagination_opportunities + expected_returns = [] + if limit != 0: + expected_returns = [ + x.model_dump(mode="json") for x in mock_test_pagination_opportunities + ] + + now = datetime.now(UTC) + start = now + end = start + timedelta(days=5) + format = "%Y-%m-%dT%H:%M:%S.%f%z" + start_string = rfc3339_strftime(start, format) + end_string = rfc3339_strftime(end, format) + + request_payload = { + "search": { + "geometry": { + "type": "Point", + "coordinates": [0, 0], + }, + "datetime": f"{start_string}/{end_string}", + "filter": { + "op": "and", + "args": [ + {"op": ">", "args": [{"property": "off_nadir"}, 0]}, + {"op": "<", "args": [{"property": "off_nadir"}, 45]}, + ], + }, + }, + "limit": limit, + } + + pagination_tester( + stapi_client=stapi_client, + endpoint=f"/products/{product_id}/opportunities", + method="POST", + limit=limit, + target="features", + expected_returns=expected_returns, + body=request_payload, + ) diff --git a/tests/test_order.py b/tests/test_order.py index d5fe5a2..09990e8 100644 --- a/tests/test_order.py +++ b/tests/test_order.py @@ -1,4 +1,5 @@ -from datetime import UTC, datetime, timedelta +import copy +from datetime import UTC, datetime, timedelta, timezone import pytest from fastapi import status @@ -7,9 +8,10 @@ from geojson_pydantic.types import Position2D from httpx import Response -from stapi_fastapi.models.order import OrderPayload +from stapi_fastapi.models.order import Order, OrderPayload, OrderStatus, OrderStatusCode +from tests.conftest import pagination_tester -from .application import MyOrderParameters +from .application import InMemoryOrderDB, MyOrderParameters from .backends import MockProductBackend from .shared import find_link @@ -18,21 +20,36 @@ END = START + timedelta(days=5) +def test_empty_order(stapi_client: TestClient): + res = stapi_client.get("/orders") + default_orders = {"type": "FeatureCollection", "features": [], "links": []} + assert res.status_code == status.HTTP_200_OK + assert res.headers["Content-Type"] == "application/geo+json" + assert res.json() == default_orders + + @pytest.fixture -def create_order_allowed_payloads() -> list[OrderPayload]: - return [ - OrderPayload( +def create_order_payloads() -> list[OrderPayload]: + datetimes = [ + ("2024-10-09T18:55:33Z", "2024-10-12T18:55:33Z"), + ("2024-10-15T18:55:33Z", "2024-10-18T18:55:33Z"), + ("2024-10-20T18:55:33Z", "2024-10-23T18:55:33Z"), + ] + payloads = [] + for start, end in datetimes: + payload = OrderPayload( geometry=Point( - type="Point", coordinates=Position2D(longitude=13.4, latitude=52.5) + type="Point", coordinates=Position2D(longitude=14.4, latitude=56.5) ), datetime=( - datetime.fromisoformat("2024-11-11T18:55:33Z"), - datetime.fromisoformat("2024-11-15T18:55:33Z"), + datetime.fromisoformat(start), + datetime.fromisoformat(end), ), filter=None, order_parameters=MyOrderParameters(s3_path="s3://my-bucket"), - ), - ] + ) + payloads.append(payload) + return payloads @pytest.fixture @@ -40,13 +57,12 @@ def new_order_response( product_id: str, product_backend: MockProductBackend, stapi_client: TestClient, - create_order_allowed_payloads: list[OrderPayload], + create_order_payloads: list[OrderPayload], ) -> Response: - product_backend._allowed_payloads = create_order_allowed_payloads - + product_backend._allowed_payloads = create_order_payloads res = stapi_client.post( f"products/{product_id}/orders", - json=create_order_allowed_payloads[0].model_dump(), + json=create_order_payloads[0].model_dump(), ) assert res.status_code == status.HTTP_201_CREATED, res.text @@ -97,23 +113,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_payloads ) -> None: order = get_order_response.json() assert order["geometry"] == { "type": "Point", - "coordinates": list(create_order_allowed_payloads[0].geometry.coordinates), + "coordinates": list(create_order_payloads[0].geometry.coordinates), } assert order["properties"]["search_parameters"]["geometry"] == { "type": "Point", - "coordinates": list(create_order_allowed_payloads[0].geometry.coordinates), + "coordinates": list(create_order_payloads[0].geometry.coordinates), } assert ( order["properties"]["search_parameters"]["datetime"] - == create_order_allowed_payloads[0].model_dump()["datetime"] + == create_order_payloads[0].model_dump()["datetime"] ) @@ -131,3 +147,113 @@ def test_order_status_after_create( assert res.status_code == status.HTTP_200_OK assert res.headers["Content-Type"] == "application/json" assert len(res.json()["statuses"]) == 1 + + +@pytest.fixture +def setup_orders_pagination( + stapi_client: TestClient, create_order_payloads +) -> list[Order]: + product_id = "test-spotlight" + orders = [] + for order in create_order_payloads: + res = stapi_client.post( + f"products/{product_id}/orders", + json=order.model_dump(), + ) + body = res.json() + orders.append(body) + + assert res.status_code == status.HTTP_201_CREATED, res.text + assert res.headers["Content-Type"] == "application/geo+json" + + return orders + + +@pytest.mark.parametrize("limit", [0, 1, 2, 4]) +def test_get_orders_pagination( + limit, setup_orders_pagination, create_order_payloads, stapi_client: TestClient +) -> None: + expected_returns = [] + if limit != 0: + for order in setup_orders_pagination: + json_link = copy.deepcopy(order["links"][0]) + json_link["type"] = "application/json" + order["links"].append(json_link) + expected_returns.append(order) + + pagination_tester( + stapi_client=stapi_client, + endpoint="/orders", + method="GET", + limit=limit, + target="features", + expected_returns=expected_returns, + ) + + +def test_token_not_found(stapi_client: TestClient) -> None: + res = stapi_client.get("/orders", params={"next": "a_token"}) + assert res.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.fixture +def order_statuses() -> dict[str, list[OrderStatus]]: + statuses = { + "test_order_id": [ + OrderStatus( + timestamp=datetime(2025, 1, 14, 2, 21, 48, 466726, tzinfo=timezone.utc), + status_code=OrderStatusCode.received, + links=[], + ), + OrderStatus( + timestamp=datetime(2025, 1, 15, 5, 20, 48, 466726, tzinfo=timezone.utc), + status_code=OrderStatusCode.accepted, + links=[], + ), + OrderStatus( + timestamp=datetime( + 2025, 1, 16, 10, 15, 32, 466726, tzinfo=timezone.utc + ), + status_code=OrderStatusCode.completed, + links=[], + ), + ] + } + return statuses + + +@pytest.mark.parametrize("limit", [0, 1, 2, 4]) +def test_get_order_status_pagination( + limit: int, + stapi_client: TestClient, + order_db: InMemoryOrderDB, + order_statuses: dict[str, list[OrderStatus]], +) -> None: + order_db._statuses = order_statuses + + order_id = "test_order_id" + expected_returns = [] + if limit != 0: + expected_returns = [x.model_dump(mode="json") for x in order_statuses[order_id]] + + pagination_tester( + stapi_client=stapi_client, + endpoint=f"/orders/{order_id}/statuses", + method="GET", + limit=limit, + target="statuses", + expected_returns=expected_returns, + ) + + +def test_get_order_statuses_bad_token( + stapi_client: TestClient, + order_db: InMemoryOrderDB, + order_statuses: dict[str, list[OrderStatus]], + limit: int = 2, +) -> None: + order_db._statuses = order_statuses + + order_id = "non_existing_order_id" + res = stapi_client.get(f"/orders/{order_id}/statuses") + assert res.status_code == status.HTTP_404_NOT_FOUND diff --git a/tests/test_product.py b/tests/test_product.py index ac8e0cf..cb2a45b 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -2,6 +2,8 @@ from fastapi import status from fastapi.testclient import TestClient +from tests.conftest import pagination_tester + def test_products_response(stapi_client: TestClient): res = stapi_client.get("/products") @@ -63,3 +65,70 @@ def test_product_order_parameters_response( json_schema = res.json() assert "properties" in json_schema assert "s3_path" in json_schema["properties"] + + +@pytest.mark.parametrize("limit", [0, 1, 2, 4]) +def test_get_products_pagination( + limit: int, + stapi_client: TestClient, + mock_product_test_spotlight, + mock_product_test_satellite_provider, +): + expected_returns = [] + if limit != 0: + for product in [ + mock_product_test_spotlight, + mock_product_test_satellite_provider, + ]: + prod = product.model_dump(mode="json", by_alias=True) + product_id = prod["id"] + prod["links"] = [ + { + "href": f"http://stapiserver/products/{product_id}", + "rel": "self", + "type": "application/json", + }, + { + "href": f"http://stapiserver/products/{product_id}/constraints", + "rel": "constraints", + "type": "application/json", + }, + { + "href": f"http://stapiserver/products/{product_id}/order-parameters", + "rel": "order-parameters", + "type": "application/json", + }, + { + "href": f"http://stapiserver/products/{product_id}/opportunities", + "rel": "opportunities", + "type": "application/json", + }, + { + "href": f"http://stapiserver/products/{product_id}/orders", + "rel": "create-order", + "type": "application/json", + }, + ] + expected_returns.append(prod) + + pagination_tester( + stapi_client=stapi_client, + endpoint="/products", + method="GET", + limit=limit, + target="products", + expected_returns=expected_returns, + ) + + +def test_token_not_found(stapi_client: TestClient) -> None: + res = stapi_client.get("/products", params={"next": "a_token"}) + assert res.status_code == status.HTTP_404_NOT_FOUND + + +def test_no_products(empty_stapi_client: TestClient): + res = empty_stapi_client.get("/products") + body = res.json() + print("hold") + assert res.status_code == status.HTTP_200_OK + assert len(body["products"]) == 0