├── tests ├── __init__.py ├── test_conformance.py ├── test_root.py ├── application.py ├── test_opportunity.py ├── test_datetime_interval.py ├── test_product.py ├── conftest.py ├── backends.py ├── test_order.py ├── shared.py └── test_opportunity_async.py ├── src └── stapi_fastapi │ ├── py.typed │ ├── types │ ├── __init__.py │ ├── filter.py │ ├── json_schema_model.py │ └── datetime_interval.py │ ├── constants.py │ ├── models │ ├── constraints.py │ ├── __init__.py │ ├── root.py │ ├── conformance.py │ ├── shared.py │ ├── opportunity.py │ ├── order.py │ └── product.py │ ├── routers │ ├── __init__.py │ ├── route_names.py │ ├── product_router.py │ └── root_router.py │ ├── responses.py │ ├── __init__.py │ ├── exceptions.py │ └── backends │ ├── __init__.py │ ├── product_backend.py │ └── root_backend.py ├── adrs ├── README.md └── constraints.md ├── .gitignore ├── bin ├── run-mypy.bash └── run-ruff.bash ├── noxfile.py ├── .github ├── pull_request_template.md └── workflows │ ├── python-publish.yml │ └── pr.yml ├── CONTRIBUTING.md ├── LICENSE ├── RELEASE.md ├── .pre-commit-config.yaml ├── pyproject.toml ├── README.md └── CHANGELOG.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/stapi_fastapi/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/stapi_fastapi/types/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adrs/README.md: -------------------------------------------------------------------------------- 1 | # ADRs 2 | 3 | - [Constraints and Opportunity Properties](./constraints.md) 4 | -------------------------------------------------------------------------------- /src/stapi_fastapi/constants.py: -------------------------------------------------------------------------------- 1 | TYPE_JSON = "application/json" 2 | TYPE_GEOJSON = "application/geo+json" 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .env 3 | .idea 4 | .python-version 5 | .venv 6 | .vscode 7 | *.sqlite 8 | /.coverage 9 | /.pytest_cache 10 | /.ruff_cache 11 | -------------------------------------------------------------------------------- /bin/run-mypy.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -Eeuo pipefail 4 | # set -x # print each command before executing 5 | 6 | MYPYPATH=src mypy src/ tests/ 7 | -------------------------------------------------------------------------------- /src/stapi_fastapi/models/constraints.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, ConfigDict 2 | 3 | 4 | class Constraints(BaseModel): 5 | model_config = ConfigDict(extra="allow") 6 | -------------------------------------------------------------------------------- /src/stapi_fastapi/routers/__init__.py: -------------------------------------------------------------------------------- 1 | from .product_router import ProductRouter 2 | from .root_router import RootRouter 3 | 4 | __all__ = [ 5 | "ProductRouter", 6 | "RootRouter", 7 | ] 8 | -------------------------------------------------------------------------------- /bin/run-ruff.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -Eeuo pipefail 4 | # set -x # print each command before executing 5 | 6 | ruff check --fix # lint python files 7 | ruff format # format python files 8 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import nox 2 | 3 | 4 | @nox.session(python=["3.12", "3.13"]) 5 | def tests(session): 6 | session.run("poetry", "install", external=True) 7 | session.run("poetry", "run", "pytest", external=True) 8 | -------------------------------------------------------------------------------- /src/stapi_fastapi/responses.py: -------------------------------------------------------------------------------- 1 | from fastapi.responses import JSONResponse 2 | 3 | from stapi_fastapi.constants import TYPE_GEOJSON 4 | 5 | 6 | class GeoJSONResponse(JSONResponse): 7 | media_type = TYPE_GEOJSON 8 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | **Related Issue(s):** 2 | 3 | - # 4 | 5 | **Proposed Changes:** 6 | 7 | 1. 8 | 2. 9 | 10 | **PR Checklist:** 11 | 12 | - [ ] I have added my changes to the CHANGELOG **or** a CHANGELOG entry is not required. 13 | -------------------------------------------------------------------------------- /src/stapi_fastapi/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .opportunity import OpportunityProperties 2 | from .product import Product, Provider, ProviderRole 3 | from .shared import Link 4 | 5 | __all__ = [ 6 | "Link", 7 | "OpportunityProperties", 8 | "Product", 9 | "Provider", 10 | "ProviderRole", 11 | ] 12 | -------------------------------------------------------------------------------- /src/stapi_fastapi/models/root.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | from stapi_fastapi.models.shared import Link 4 | 5 | 6 | class RootResponse(BaseModel): 7 | id: str 8 | conformsTo: list[str] = Field(default_factory=list) 9 | title: str = "" 10 | description: str = "" 11 | links: list[Link] = Field(default_factory=list) 12 | -------------------------------------------------------------------------------- /src/stapi_fastapi/__init__.py: -------------------------------------------------------------------------------- 1 | from .models import ( 2 | Link, 3 | OpportunityProperties, 4 | Product, 5 | Provider, 6 | ProviderRole, 7 | ) 8 | from .routers import ProductRouter, RootRouter 9 | 10 | __all__ = [ 11 | "Link", 12 | "OpportunityProperties", 13 | "Product", 14 | "ProductRouter", 15 | "Provider", 16 | "ProviderRole", 17 | "RootRouter", 18 | ] 19 | -------------------------------------------------------------------------------- /src/stapi_fastapi/models/conformance.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | CORE = "https://stapi.example.com/v0.1.0/core" 4 | OPPORTUNITIES = "https://stapi.example.com/v0.1.0/opportunities" 5 | ASYNC_OPPORTUNITIES = "https://stapi.example.com/v0.1.0/async-opportunities" 6 | 7 | 8 | class Conformance(BaseModel): 9 | conforms_to: list[str] = Field( 10 | default_factory=list, serialization_alias="conformsTo" 11 | ) 12 | -------------------------------------------------------------------------------- /tests/test_conformance.py: -------------------------------------------------------------------------------- 1 | from fastapi import status 2 | from fastapi.testclient import TestClient 3 | 4 | from stapi_fastapi.models.conformance import CORE 5 | 6 | 7 | def test_conformance(stapi_client: TestClient) -> None: 8 | res = stapi_client.get("/conformance") 9 | 10 | assert res.status_code == status.HTTP_200_OK 11 | assert res.headers["Content-Type"] == "application/json" 12 | 13 | body = res.json() 14 | 15 | assert body["conformsTo"] == [CORE] 16 | -------------------------------------------------------------------------------- /src/stapi_fastapi/types/filter.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Any 2 | 3 | from pydantic import BeforeValidator 4 | from pygeofilter.parsers import cql2_json 5 | 6 | 7 | def validate(v: dict[str, Any]) -> dict[str, Any]: 8 | if v: 9 | try: 10 | cql2_json.parse({"filter": v}) 11 | except Exception as e: 12 | raise ValueError("Filter is not valid cql2-json") from e 13 | return v 14 | 15 | 16 | type CQL2Filter = Annotated[ 17 | dict, 18 | BeforeValidator(validate), 19 | ] 20 | -------------------------------------------------------------------------------- /src/stapi_fastapi/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | from fastapi import HTTPException, status 4 | 5 | 6 | class StapiException(HTTPException): 7 | pass 8 | 9 | 10 | class ConstraintsException(StapiException): 11 | def __init__(self, detail: Any) -> None: 12 | super().__init__(status.HTTP_422_UNPROCESSABLE_ENTITY, detail) 13 | 14 | 15 | class NotFoundException(StapiException): 16 | def __init__(self, detail: Optional[Any] = None) -> None: 17 | super().__init__(status.HTTP_404_NOT_FOUND, detail) 18 | -------------------------------------------------------------------------------- /src/stapi_fastapi/backends/__init__.py: -------------------------------------------------------------------------------- 1 | from .product_backend import ( 2 | CreateOrder, 3 | GetOpportunityCollection, 4 | SearchOpportunities, 5 | SearchOpportunitiesAsync, 6 | ) 7 | from .root_backend import ( 8 | GetOpportunitySearchRecord, 9 | GetOpportunitySearchRecords, 10 | GetOrder, 11 | GetOrders, 12 | GetOrderStatuses, 13 | ) 14 | 15 | __all__ = [ 16 | "CreateOrder", 17 | "GetOpportunityCollection", 18 | "GetOpportunitySearchRecord", 19 | "GetOpportunitySearchRecords", 20 | "GetOrder", 21 | "GetOrders", 22 | "GetOrderStatuses", 23 | "SearchOpportunities", 24 | "SearchOpportunitiesAsync", 25 | ] 26 | -------------------------------------------------------------------------------- /src/stapi_fastapi/types/json_schema_model.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Any 2 | 3 | from pydantic import ( 4 | BaseModel, 5 | PlainSerializer, 6 | PlainValidator, 7 | WithJsonSchema, 8 | ) 9 | 10 | 11 | def validate(v: Any) -> Any: 12 | if not issubclass(v, BaseModel): 13 | raise RuntimeError("BaseModel class required") 14 | return v 15 | 16 | 17 | def serialize(v: type[BaseModel]) -> dict[str, Any]: 18 | return v.model_json_schema() 19 | 20 | 21 | type JsonSchemaModel = Annotated[ 22 | type[BaseModel], 23 | PlainValidator(validate), 24 | PlainSerializer(serialize), 25 | WithJsonSchema({"type": "object"}), 26 | ] 27 | -------------------------------------------------------------------------------- /src/stapi_fastapi/routers/route_names.py: -------------------------------------------------------------------------------- 1 | # Root 2 | ROOT = "root" 3 | CONFORMANCE = "conformance" 4 | 5 | # Product 6 | LIST_PRODUCTS = "list-products" 7 | LIST_PRODUCTS = "list-products" 8 | GET_PRODUCT = "get-product" 9 | GET_CONSTRAINTS = "get-constraints" 10 | GET_ORDER_PARAMETERS = "get-order-parameters" 11 | 12 | # Opportunity 13 | LIST_OPPORTUNITY_SEARCH_RECORDS = "list-opportunity-search-records" 14 | GET_OPPORTUNITY_SEARCH_RECORD = "get-opportunity-search-record" 15 | SEARCH_OPPORTUNITIES = "search-opportunities" 16 | GET_OPPORTUNITY_COLLECTION = "get-opportunity-collection" 17 | 18 | # Order 19 | LIST_ORDERS = "list-orders" 20 | GET_ORDER = "get-order" 21 | LIST_ORDER_STATUSES = "list-order-statuses" 22 | CREATE_ORDER = "create-order" 23 | -------------------------------------------------------------------------------- /tests/test_root.py: -------------------------------------------------------------------------------- 1 | from fastapi import status 2 | from fastapi.testclient import TestClient 3 | 4 | from stapi_fastapi.models.conformance import CORE 5 | 6 | 7 | def test_root(stapi_client: TestClient, assert_link) -> None: 8 | res = stapi_client.get("/") 9 | 10 | assert res.status_code == status.HTTP_200_OK 11 | assert res.headers["Content-Type"] == "application/json" 12 | 13 | body = res.json() 14 | 15 | assert body["conformsTo"] == [CORE] 16 | 17 | assert_link("GET /", body, "self", "/") 18 | assert_link("GET /", body, "service-description", "/openapi.json") 19 | assert_link("GET /", body, "service-docs", "/docs", media_type="text/html") 20 | assert_link("GET /", body, "conformance", "/conformance") 21 | assert_link("GET /", body, "products", "/products") 22 | assert_link("GET /", body, "orders", "/orders", media_type="application/geo+json") 23 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | TODO: Move most of the readme into here. 4 | 5 | ## Design Principles 6 | 7 | ### Route Names and Links 8 | 9 | The route names used in route defintions should be constants in `stapi_fastapi.routers.route_names`. This 10 | makes it easier to populate these links in numerous places, including in apps that use this library. 11 | 12 | The general scheme for route names should follow: 13 | 14 | - `create-{x}` - create a resource `x` 15 | - `create-{x}-for-{y}` - create a resource `x` as a sub-resource or associated resource of `y` 16 | - `get-{x}` - retrieve a resource `x` 17 | - `list-{xs}` - retrieve a list of resources of type `x` 18 | - `list-{xs}-for-{y}` - retrieve a list of subresources of type `x` of a resource `y` 19 | - `set-{x}` - update an existing resource `x` 20 | - `set-{x}-for-{y}` - set a sub-resource `x` of a resource `y`, e.g., `set-status-for-order` 21 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build Python Package 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | release: 11 | types: 12 | - published 13 | 14 | jobs: 15 | build-package: 16 | runs-on: ubuntu-latest 17 | environment: 18 | name: pypi 19 | url: https://pypi.org/p/stapi-fastapi 20 | permissions: 21 | id-token: write 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Set up Python 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: "3.12" 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install build 32 | pip install . 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package distributions to PyPI 36 | uses: pypa/gh-action-pypi-publish@release/v1 37 | if: startsWith(github.ref, 'refs/tags') 38 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PR Checks 2 | 3 | on: 4 | pull_request: 5 | branches: ["main"] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ["3.12", "3.13"] 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-python@v5 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Cache dependencies 19 | uses: actions/cache@v3 20 | with: 21 | path: | 22 | ~/.cache/pip 23 | .venv 24 | key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }} 25 | restore-keys: | 26 | ${{ runner.os }}-pip-${{ matrix.python-version }}- 27 | ${{ runner.os }}-pip- 28 | - name: Install 29 | run: | 30 | python -m pip install poetry==1.7.1 31 | poetry install --with=dev 32 | - name: Lint 33 | run: | 34 | poetry run pre-commit run --all-files 35 | - name: Test 36 | run: poetry run pytest 37 | -------------------------------------------------------------------------------- /src/stapi_fastapi/models/shared.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from pydantic import ( 4 | AnyUrl, 5 | BaseModel, 6 | ConfigDict, 7 | SerializerFunctionWrapHandler, 8 | model_serializer, 9 | ) 10 | 11 | 12 | class Link(BaseModel): 13 | href: AnyUrl 14 | rel: str 15 | type: str | None = None 16 | title: str | None = None 17 | method: str | None = None 18 | headers: dict[str, str | list[str]] | None = None 19 | body: Any = None 20 | 21 | model_config = ConfigDict(extra="allow") 22 | 23 | # redefining init is a hack to get str type to validate for `href`, 24 | # as str is ultimately coerced into an AnyUrl automatically anyway 25 | def __init__(self, href: AnyUrl | str, **kwargs): 26 | super().__init__(href=href, **kwargs) 27 | 28 | # overriding the default serialization to filter None field values from 29 | # dumped json 30 | @model_serializer(mode="wrap", when_used="json") 31 | def serialize(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: 32 | return {k: v for k, v in handler(self).items() if v is not None} 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 STAPI FastAPI Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/stapi_fastapi/types/datetime_interval.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Annotated, Any, Callable 3 | 4 | from pydantic import ( 5 | AfterValidator, 6 | AwareDatetime, 7 | BeforeValidator, 8 | WithJsonSchema, 9 | WrapSerializer, 10 | ) 11 | 12 | 13 | def validate_before(value: Any) -> tuple[datetime, datetime]: 14 | if isinstance(value, str): 15 | start, end = value.split("/", 1) 16 | return (datetime.fromisoformat(start), datetime.fromisoformat(end)) 17 | return value 18 | 19 | 20 | def validate_after(value: tuple[datetime, datetime]): 21 | if value[1] < value[0]: 22 | raise ValueError("end before start") 23 | return value 24 | 25 | 26 | def serialize( 27 | value: tuple[datetime, datetime], 28 | serializer: Callable[[tuple[datetime, datetime]], tuple[str, str]], 29 | ) -> str: 30 | del serializer # unused 31 | return f"{value[0].isoformat()}/{value[1].isoformat()}" 32 | 33 | 34 | type DatetimeInterval = Annotated[ 35 | tuple[AwareDatetime, AwareDatetime], 36 | BeforeValidator(validate_before), 37 | AfterValidator(validate_after), 38 | WrapSerializer(serialize, return_type=str), 39 | WithJsonSchema({"type": "string"}, mode="serialization"), 40 | ] 41 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Releasing stapi-fastapi 2 | 3 | Publishing a stapi-fastapi package build to PyPI is triggered by publishing a 4 | GitHub release. Tags are the [semantic version number](https://semver.org/) 5 | proceeded by a `v`, such as `v0.0.1`. 6 | 7 | Release notes for the changes for each release should be tracked in 8 | [CHANGELOG.md](./CHANGELOG.md). The notes for each release in GitHub should 9 | generally match those in the CHANGELOG. 10 | 11 | ## Release process 12 | 13 | 1. Prepare the release. 14 | 1. Figure out the next release version (following semantic versioning 15 | conventions). 16 | 1. Ensure [CHANGELOG.md](./CHANGELOG.md) has all necessary changes and 17 | release notes under this next release version. Typically this step is 18 | simply a matter of adding the header for the next version below 19 | `Unreleased` then reviewing the list of changes therein. 20 | 1. Ensure links are tracked as best as possible to relevant commits and/or 21 | PRs. 22 | 1. Make a PR with the release prep changes, get it reviewed, and merge. 23 | 1. Draft a new GitHub release. 24 | 1. Create a new tag with the release version prefixed with the character `v`. 25 | 1. Title the release the same name as the tag. 26 | 1. Copy the release notes from [CHANGELOG.md](./CHANGELOG.md) for this 27 | release version into the release description. 28 | 1. Publish the release and ensure it builds and pushes to PyPI successfully. 29 | -------------------------------------------------------------------------------- /tests/application.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from collections.abc import AsyncIterator 4 | from contextlib import asynccontextmanager 5 | from typing import Any 6 | 7 | from fastapi import FastAPI 8 | 9 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 10 | 11 | from stapi_fastapi.models.conformance import ASYNC_OPPORTUNITIES, CORE, OPPORTUNITIES 12 | from stapi_fastapi.routers.root_router import RootRouter 13 | from tests.backends import ( 14 | mock_get_opportunity_search_record, 15 | mock_get_opportunity_search_records, 16 | mock_get_order, 17 | mock_get_order_statuses, 18 | mock_get_orders, 19 | ) 20 | from tests.shared import ( 21 | InMemoryOpportunityDB, 22 | InMemoryOrderDB, 23 | product_test_satellite_provider_sync_opportunity, 24 | product_test_spotlight_sync_async_opportunity, 25 | ) 26 | 27 | 28 | @asynccontextmanager 29 | async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: 30 | try: 31 | yield { 32 | "_orders_db": InMemoryOrderDB(), 33 | "_opportunities_db": InMemoryOpportunityDB(), 34 | } 35 | finally: 36 | pass 37 | 38 | 39 | root_router = RootRouter( 40 | get_orders=mock_get_orders, 41 | get_order=mock_get_order, 42 | get_order_statuses=mock_get_order_statuses, 43 | get_opportunity_search_records=mock_get_opportunity_search_records, 44 | get_opportunity_search_record=mock_get_opportunity_search_record, 45 | conformances=[CORE, OPPORTUNITIES, ASYNC_OPPORTUNITIES], 46 | ) 47 | root_router.add_product(product_test_spotlight_sync_async_opportunity) 48 | root_router.add_product(product_test_satellite_provider_sync_opportunity) 49 | app: FastAPI = FastAPI(lifespan=lifespan) 50 | app.include_router(root_router, prefix="") 51 | -------------------------------------------------------------------------------- /tests/test_opportunity.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | 4 | from stapi_fastapi.models.opportunity import ( 5 | OpportunityCollection, 6 | ) 7 | 8 | from .shared import create_mock_opportunity, pagination_tester 9 | 10 | 11 | def test_search_opportunities_response( 12 | stapi_client: TestClient, assert_link, opportunity_search 13 | ) -> None: 14 | product_id = "test-spotlight" 15 | url = f"/products/{product_id}/opportunities" 16 | 17 | response = stapi_client.post(url, json=opportunity_search) 18 | 19 | assert response.status_code == 200, f"Failed for product: {product_id}" 20 | body = response.json() 21 | 22 | # Validate the opportunity was returned 23 | assert len(body["features"]) == 1 24 | 25 | try: 26 | _ = OpportunityCollection(**body) 27 | except Exception as _: 28 | pytest.fail("response is not an opportunity collection") 29 | 30 | assert_link( 31 | f"POST {url}", 32 | body, 33 | "create-order", 34 | f"/products/{product_id}/orders", 35 | method="POST", 36 | ) 37 | 38 | 39 | @pytest.mark.parametrize("limit", [0, 1, 2, 4]) 40 | def test_search_opportunities_pagination( 41 | limit: int, 42 | stapi_client: TestClient, 43 | opportunity_search, 44 | ) -> None: 45 | mock_pagination_opportunities = [create_mock_opportunity() for __ in range(3)] 46 | stapi_client.app_state["_opportunities"] = mock_pagination_opportunities 47 | product_id = "test-spotlight" 48 | expected_returns = [] 49 | if limit != 0: 50 | expected_returns = [ 51 | x.model_dump(mode="json") for x in mock_pagination_opportunities 52 | ] 53 | 54 | pagination_tester( 55 | stapi_client=stapi_client, 56 | url=f"/products/{product_id}/opportunities", 57 | method="POST", 58 | limit=limit, 59 | target="features", 60 | expected_returns=expected_returns, 61 | body=opportunity_search, 62 | ) 63 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: ruff 5 | name: Format and lint with ruff 6 | entry: ./bin/run-ruff.bash 7 | language: system 8 | types: [python] 9 | pass_filenames: false 10 | verbose: true 11 | - id: mypy 12 | name: Check typing with mypy 13 | entry: ./bin/run-mypy.bash 14 | language: system 15 | types: [python] 16 | pass_filenames: false 17 | verbose: true 18 | - id: pymarkdown 19 | name: Markdownlint 20 | description: Run markdownlint on Markdown files 21 | entry: pymarkdown scan 22 | language: python 23 | files: \.(md|mdown|markdown)$ 24 | exclude: ^.github/pull_request_template.md$ 25 | - id: check-added-large-files 26 | name: Check for added large files 27 | entry: check-added-large-files 28 | language: system 29 | - id: check-toml 30 | name: Check Toml 31 | entry: check-toml 32 | language: system 33 | types: [toml] 34 | - id: check-yaml 35 | name: Check Yaml 36 | entry: check-yaml 37 | language: system 38 | types: [yaml] 39 | - id: mixed-line-ending 40 | name: Check mixed line endings 41 | entry: mixed-line-ending 42 | language: system 43 | types: [text] 44 | stages: [pre-commit, pre-push, manual] 45 | - id: end-of-file-fixer 46 | name: Fix End of Files 47 | entry: end-of-file-fixer 48 | language: system 49 | types: [text] 50 | stages: [pre-commit, pre-push, manual] 51 | - id: trailing-whitespace 52 | name: Trim Trailing Whitespace 53 | entry: trailing-whitespace-fixer 54 | language: system 55 | types: [text] 56 | stages: [pre-commit, pre-push, manual] 57 | - id: check-merge-conflict 58 | name: Check merge conflicts 59 | entry: check-merge-conflict 60 | language: system 61 | - id: no-commit-to-branch 62 | name: Check not committting to main 63 | entry: no-commit-to-branch 64 | language: system 65 | args: ["--branch", "main"] 66 | pass_filenames: false 67 | -------------------------------------------------------------------------------- /tests/test_datetime_interval.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime, timedelta 2 | from itertools import product 3 | from zoneinfo import ZoneInfo 4 | 5 | from pydantic import BaseModel, ValidationError 6 | from pyrfc3339.utils import format_timezone 7 | from pytest import mark, raises 8 | 9 | from stapi_fastapi.types.datetime_interval import DatetimeInterval 10 | 11 | EUROPE_BERLIN = ZoneInfo("Europe/Berlin") 12 | 13 | 14 | class Model(BaseModel): 15 | datetime: DatetimeInterval 16 | 17 | 18 | def rfc3339_strftime(dt: datetime, format: str) -> str: 19 | tds = int(round(dt.tzinfo.utcoffset(dt).total_seconds())) # type: ignore 20 | long = format_timezone(tds) 21 | short = "Z" 22 | 23 | format = format.replace("%z", long).replace("%Z", short if tds == 0 else long) 24 | return dt.strftime(format) 25 | 26 | 27 | @mark.parametrize( 28 | "value", 29 | ( 30 | "", 31 | "2024-01-29/2024-01-30", 32 | "2024-01-29T12:00:00/2024-01-30T12:00:00", 33 | "2024-01-29T12:00:00Z/2024-01-28T12:00:00Z", 34 | ), 35 | ) 36 | def test_invalid_values(value: str): 37 | with raises(ValidationError): 38 | Model.model_validate_strings({"datetime": value}) 39 | 40 | 41 | @mark.parametrize( 42 | "tz, format", 43 | product( 44 | (UTC, EUROPE_BERLIN), 45 | ( 46 | "%Y-%m-%dT%H:%M:%S.%f%Z", 47 | "%Y-%m-%dT%H:%M:%S.%f%z", 48 | "%Y-%m-%d %H:%M:%S.%f%Z", 49 | "%Y-%m-%dt%H:%M:%S.%f%Z", 50 | "%Y-%m-%d_%H:%M:%S.%f%Z", 51 | ), 52 | ), 53 | ) 54 | def test_deserialization(tz: ZoneInfo, format: str): 55 | start = datetime.now(tz) 56 | end = start + timedelta(hours=1) 57 | value = f"{rfc3339_strftime(start, format)}/{rfc3339_strftime(end, format)}" 58 | 59 | model = Model.model_validate_json(f'{{"datetime":"{value}"}}') 60 | 61 | assert model.datetime == (start, end) 62 | 63 | 64 | @mark.parametrize("tz", (UTC, EUROPE_BERLIN)) 65 | def test_serialize(tz): 66 | start = datetime.now(tz) 67 | end = start + timedelta(hours=1) 68 | model = Model(datetime=(start, end)) 69 | 70 | format = "%Y-%m-%dT%H:%M:%S.%f%z" 71 | expected = f"{rfc3339_strftime(start, format)}/{rfc3339_strftime(end, format)}" 72 | 73 | obj = model.model_dump() 74 | assert obj["datetime"] == expected 75 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "stapi-fastapi" 3 | # placeholder version filled by poetry-dynamic-versioning 4 | version = "0.0.0" 5 | description = "Sensor Tasking API (STAPI) with FastAPI" 6 | authors = ["Christian Wygoda "] 7 | license = "MIT" 8 | readme = "README.md" 9 | packages = [{include = "stapi_fastapi", from="src"}] 10 | 11 | [tool.poetry.dependencies] 12 | python = ">=3.12,<4.0" 13 | fastapi = ">=0.115.0" 14 | pydantic = ">=2.10" 15 | geojson-pydantic = ">=1.1" 16 | pygeofilter = ">=0.2" 17 | returns = ">=0.23" 18 | 19 | [tool.poetry.group.dev] 20 | optional = true 21 | 22 | [tool.poetry.group.dev.dependencies] 23 | httpx = ">=0.27.0" 24 | nox = ">=2024.4.15" 25 | mypy = ">=1.13.0" 26 | pre-commit = ">=4.1.0" 27 | pre-commit-hooks = ">=4.6.0" 28 | pydantic-settings = ">=2.2.1" 29 | pymarkdownlnt = ">=0.9.25" 30 | pyrfc3339 = ">=1.1" 31 | pytest = ">=8.1.1" 32 | pytest-coverage = ">=0.0" 33 | ruff = ">=0.9" 34 | types-pyRFC3339 = ">=1.1.1" 35 | uvicorn = ">=0.29.0" 36 | 37 | [tool.poetry.scripts] 38 | dev = "stapi_fastapi.__dev__:cli" 39 | 40 | [tool.ruff] 41 | line-length = 88 42 | 43 | [tool.ruff.format] 44 | quote-style = 'double' 45 | 46 | [tool.ruff.lint] 47 | extend-ignore = ["E501", "UP007", "UP034"] 48 | select = [ 49 | "C9", 50 | "E", 51 | "F", 52 | "I", 53 | "W", 54 | "UP" 55 | ] 56 | 57 | [tool.ruff.lint.mccabe] 58 | max-complexity = 8 59 | 60 | [tool.coverage.report] 61 | show_missing = true 62 | skip_empty = true 63 | sort = "Cover" 64 | omit = [ 65 | "tests/**/*.py", 66 | "stapi_fastapi/__dev__.py", 67 | ] 68 | 69 | [tool.pytest.ini_options] 70 | addopts="--cov=stapi_fastapi" 71 | filterwarnings = [ 72 | "ignore:The 'app' shortcut is now deprecated.:DeprecationWarning", 73 | "ignore:Pydantic serializer warnings:UserWarning", 74 | ] 75 | markers = [ 76 | "mock_products", 77 | ] 78 | 79 | [build-system] 80 | requires = [ 81 | "poetry-core>=1.0.0", 82 | "poetry-dynamic-versioning>=1.0.0,<2.0.0",] 83 | build-backend = "poetry_dynamic_versioning.backend" 84 | 85 | [tool.poetry-dynamic-versioning] 86 | enable = true 87 | 88 | [[tool.mypy.overrides]] 89 | module = "pygeofilter.parsers.*" 90 | ignore_missing_imports = true 91 | 92 | # [tool.mypy] 93 | #plugins = ['pydantic.mypy'] 94 | 95 | [tool.pymarkdown] 96 | plugins.md013.line_length = 120 97 | plugins.md024.enabled = false # duplicate headers 98 | -------------------------------------------------------------------------------- /src/stapi_fastapi/models/opportunity.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | from typing import Any, Literal, TypeVar 3 | 4 | from geojson_pydantic import Feature, FeatureCollection 5 | from geojson_pydantic.geometries import Geometry 6 | from pydantic import AwareDatetime, BaseModel, ConfigDict, Field 7 | 8 | from stapi_fastapi.models.shared import Link 9 | from stapi_fastapi.types.datetime_interval import DatetimeInterval 10 | from stapi_fastapi.types.filter import CQL2Filter 11 | 12 | 13 | # Copied and modified from https://github.com/stac-utils/stac-pydantic/blob/main/stac_pydantic/item.py#L11 14 | class OpportunityProperties(BaseModel): 15 | datetime: DatetimeInterval 16 | product_id: str 17 | model_config = ConfigDict(extra="allow") 18 | 19 | 20 | class OpportunityPayload(BaseModel): 21 | datetime: DatetimeInterval 22 | geometry: Geometry 23 | filter: CQL2Filter | None = None 24 | 25 | next: str | None = None 26 | limit: int = 10 27 | 28 | model_config = ConfigDict(strict=True) 29 | 30 | def search_body(self) -> dict[str, Any]: 31 | return self.model_dump(mode="json", include={"datetime", "geometry", "filter"}) 32 | 33 | def body(self) -> dict[str, Any]: 34 | return self.model_dump(mode="json") 35 | 36 | 37 | G = TypeVar("G", bound=Geometry) 38 | P = TypeVar("P", bound=OpportunityProperties) 39 | 40 | 41 | class Opportunity(Feature[G, P]): 42 | type: Literal["Feature"] = "Feature" 43 | links: list[Link] = Field(default_factory=list) 44 | 45 | 46 | class OpportunityCollection(FeatureCollection[Opportunity[G, P]]): 47 | type: Literal["FeatureCollection"] = "FeatureCollection" 48 | links: list[Link] = Field(default_factory=list) 49 | id: str | None = None 50 | 51 | 52 | class OpportunitySearchStatusCode(StrEnum): 53 | received = "received" 54 | in_progress = "in_progress" 55 | failed = "failed" 56 | canceled = "canceled" 57 | completed = "completed" 58 | 59 | 60 | class OpportunitySearchStatus(BaseModel): 61 | timestamp: AwareDatetime 62 | status_code: OpportunitySearchStatusCode 63 | reason_code: str | None = None 64 | reason_text: str | None = None 65 | links: list[Link] = Field(default_factory=list) 66 | 67 | 68 | class OpportunitySearchRecord(BaseModel): 69 | id: str 70 | product_id: str 71 | opportunity_request: OpportunityPayload 72 | status: OpportunitySearchStatus 73 | links: list[Link] = Field(default_factory=list) 74 | 75 | 76 | class OpportunitySearchRecords(BaseModel): 77 | search_records: list[OpportunitySearchRecord] 78 | links: list[Link] = Field(default_factory=list) 79 | 80 | 81 | class Prefer(StrEnum): 82 | respond_async = "respond-async" 83 | wait = "wait" 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # STAPI FastAPI - Sensor Tasking API with FastAPI 2 | 3 | > [!NOTE] 4 | > This repository has been archived, and the code has been moved to https://github.com/stapi-spec/pystapi/tree/main/stapi-fastapi. 5 | 6 | > [!WARNING] 7 | > The whole [STAPI spec] is very much a work in progress, so things are 8 | guaranteed to be not correct. 9 | 10 | ## Usage 11 | 12 | STAPI FastAPI provides an `fastapi.APIRouter` which must be included in 13 | `fastapi.FastAPI` instance. 14 | 15 | ### Pagination 16 | 17 | 4 endpoints currently offer pagination: 18 | `GET`: `'/orders`, `/products`, `/orders/{order_id}/statuses` 19 | `POST`: `/opportunities`. 20 | 21 | Pagination is token based and follows recommendations in the [STAC API pagination]. 22 | Limit and token are passed in as query params for `GET` endpoints, and via the body as 23 | separate key/value pairs for `POST` requests. 24 | 25 | If pagination is available and more records remain the response object will contain a 26 | `next` link object that can be used to get the next page of results. No `next` `Link` 27 | returned indicates there are no further records available. 28 | 29 | `limit` defaults to 10 and maxes at 100. 30 | 31 | ## ADRs 32 | 33 | ADRs can be found in in the [adrs](./adrs/README.md) directory. 34 | 35 | ## Development 36 | 37 | It's 2024 and we still need to pick our poison for a 2024 dependency management 38 | solution. This project picks [poetry] for now. 39 | 40 | ### Dev Setup 41 | 42 | Setup is managed with `poetry` and `pre-commit`. It's recommended to install 43 | the project into a virtual environment. Bootstrapping a development environment 44 | could look something like this: 45 | 46 | ```commandline 47 | python -m venv .venv 48 | source .venv/bin/activate 49 | pip install poetry # if not already installed to the system 50 | poetry install --with dev 51 | pre-commit install 52 | ``` 53 | 54 | ### Test Suite 55 | 56 | A `pytest` based test suite is provided, and can be run simply using the 57 | command `pytest`. 58 | 59 | ### Dev Server 60 | 61 | This project cannot be run on its own because it does not have any backend 62 | implementations. However, a minimal test implementation is provided in 63 | [`./tests/application.py`](./tests/application.py). It can be run with 64 | `uvicorn` as a way to interact with the API and to view the OpenAPI 65 | documentation. Run it like so from the project root: 66 | 67 | ```commandline 68 | uvicorn application:app --app-dir ./tests --reload 69 | ``` 70 | 71 | With the `uvicorn` defaults the app should be accessible at 72 | `http://localhost:8000`. 73 | 74 | ### Implementing a backend 75 | 76 | - The test suite assumes the backend can be instantiated without any parameters 77 | required by the constructor. 78 | 79 | [STAPI spec]: https://github.com/stapi-spec/stapi-spec 80 | [poetry]: https://python-poetry.org/ 81 | [STAC API pagination]: https://github.com/radiantearth/stac-api-spec/blob/release/v1.0.0/item-search/examples.md#paging-examples 82 | -------------------------------------------------------------------------------- /src/stapi_fastapi/models/order.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterator 2 | from enum import StrEnum 3 | from typing import Any, Generic, Literal, Optional, TypeVar, Union 4 | 5 | from geojson_pydantic.base import _GeoJsonBase 6 | from geojson_pydantic.geometries import Geometry 7 | from pydantic import ( 8 | AwareDatetime, 9 | BaseModel, 10 | ConfigDict, 11 | Field, 12 | StrictStr, 13 | field_validator, 14 | ) 15 | 16 | from stapi_fastapi.models.opportunity import OpportunityProperties 17 | from stapi_fastapi.models.shared import Link 18 | from stapi_fastapi.types.datetime_interval import DatetimeInterval 19 | from stapi_fastapi.types.filter import CQL2Filter 20 | 21 | Props = TypeVar("Props", bound=Union[dict[str, Any], BaseModel]) 22 | Geom = TypeVar("Geom", bound=Geometry) 23 | 24 | 25 | class OrderParameters(BaseModel): 26 | model_config = ConfigDict(extra="forbid") 27 | 28 | 29 | OPP = TypeVar("OPP", bound=OpportunityProperties) 30 | ORP = TypeVar("ORP", bound=OrderParameters) 31 | 32 | 33 | class OrderStatusCode(StrEnum): 34 | received = "received" 35 | accepted = "accepted" 36 | rejected = "rejected" 37 | completed = "completed" 38 | canceled = "canceled" 39 | scheduled = "scheduled" 40 | held = "held" 41 | processing = "processing" 42 | reserved = "reserved" 43 | tasked = "tasked" 44 | user_canceled = "user_canceled" 45 | 46 | 47 | class OrderStatus(BaseModel): 48 | timestamp: AwareDatetime 49 | status_code: OrderStatusCode 50 | reason_code: Optional[str] = None 51 | reason_text: Optional[str] = None 52 | links: list[Link] = Field(default_factory=list) 53 | 54 | model_config = ConfigDict(extra="allow") 55 | 56 | 57 | class OrderStatuses[T: OrderStatus](BaseModel): 58 | statuses: list[T] 59 | links: list[Link] = Field(default_factory=list) 60 | 61 | 62 | class OrderSearchParameters(BaseModel): 63 | datetime: DatetimeInterval 64 | geometry: Geometry 65 | # TODO: validate the CQL2 filter? 66 | filter: CQL2Filter | None = None 67 | 68 | 69 | class OrderProperties[T: OrderStatus](BaseModel): 70 | product_id: str 71 | created: AwareDatetime 72 | status: T 73 | 74 | search_parameters: OrderSearchParameters 75 | opportunity_properties: dict[str, Any] 76 | order_parameters: dict[str, Any] 77 | 78 | model_config = ConfigDict(extra="allow") 79 | 80 | 81 | # derived from geojson_pydantic.Feature 82 | class Order(_GeoJsonBase): 83 | # We need to enforce that orders have an id defined, as that is required to 84 | # retrieve them via the API 85 | id: StrictStr 86 | type: Literal["Feature"] = "Feature" 87 | 88 | geometry: Geometry = Field(...) 89 | properties: OrderProperties = Field(...) 90 | 91 | links: list[Link] = Field(default_factory=list) 92 | 93 | __geojson_exclude_if_none__ = {"bbox", "id"} 94 | 95 | @field_validator("geometry", mode="before") 96 | def set_geometry(cls, geometry: Any) -> Any: 97 | """set geometry from geo interface or input""" 98 | if hasattr(geometry, "__geo_interface__"): 99 | return geometry.__geo_interface__ 100 | 101 | return geometry 102 | 103 | 104 | # derived from geojson_pydantic.FeatureCollection 105 | class OrderCollection(_GeoJsonBase): 106 | type: Literal["FeatureCollection"] = "FeatureCollection" 107 | features: list[Order] 108 | links: list[Link] = Field(default_factory=list) 109 | 110 | def __iter__(self) -> Iterator[Order]: # type: ignore [override] 111 | """iterate over features""" 112 | return iter(self.features) 113 | 114 | def __len__(self) -> int: 115 | """return features length""" 116 | return len(self.features) 117 | 118 | def __getitem__(self, index: int) -> Order: 119 | """get feature at a given index""" 120 | return self.features[index] 121 | 122 | 123 | class OrderPayload(BaseModel, Generic[ORP]): 124 | datetime: DatetimeInterval 125 | geometry: Geometry 126 | # TODO: validate the CQL2 filter? 127 | filter: CQL2Filter | None = None 128 | 129 | order_parameters: ORP 130 | 131 | model_config = ConfigDict(strict=True) 132 | -------------------------------------------------------------------------------- /src/stapi_fastapi/backends/product_backend.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Coroutine 4 | from typing import Any, Callable 5 | 6 | from fastapi import Request 7 | from returns.maybe import Maybe 8 | from returns.result import ResultE 9 | 10 | from stapi_fastapi.models.opportunity import ( 11 | Opportunity, 12 | OpportunityCollection, 13 | OpportunityPayload, 14 | OpportunitySearchRecord, 15 | ) 16 | from stapi_fastapi.models.order import Order, OrderPayload 17 | from stapi_fastapi.routers.product_router import ProductRouter 18 | 19 | SearchOpportunities = Callable[ 20 | [ProductRouter, OpportunityPayload, str | None, int, Request], 21 | Coroutine[Any, Any, ResultE[tuple[list[Opportunity], Maybe[str]]]], 22 | ] 23 | """ 24 | Type alias for an async function that searches for ordering opportunities for the given 25 | search parameters. 26 | 27 | Args: 28 | product_router (ProductRouter): The product router. 29 | search (OpportunityPayload): The search parameters. 30 | next (str | None): A pagination token. 31 | limit (int): The maximum number of opportunities to return in a page. 32 | request (Request): FastAPI's Request object. 33 | 34 | Returns: 35 | A tuple containing a list of opportunities and a pagination token. 36 | 37 | - Should return returns.result.Success[tuple[list[Opportunity], returns.maybe.Some[str]]] if including a pagination token 38 | - Should return returns.result.Success[tuple[list[Opportunity], returns.maybe.Nothing]] if not including a pagination token 39 | - Returning returns.result.Failure[Exception] will result in a 500. 40 | 41 | Note: 42 | Backends must validate search constraints and return 43 | returns.result.Failure[stapi_fastapi.exceptions.ConstraintsException] if not valid. 44 | """ 45 | 46 | SearchOpportunitiesAsync = Callable[ 47 | [ProductRouter, OpportunityPayload, Request], 48 | Coroutine[Any, Any, ResultE[OpportunitySearchRecord]], 49 | ] 50 | """ 51 | Type alias for an async function that starts an asynchronous search for ordering 52 | opportunities for the given search parameters. 53 | 54 | Args: 55 | product_router (ProductRouter): The product router. 56 | search (OpportunityPayload): The search parameters. 57 | request (Request): FastAPI's Request object. 58 | 59 | Returns: 60 | - Should return returns.result.Success[OpportunitySearchRecord] 61 | - Returning returns.result.Failure[Exception] will result in a 500. 62 | 63 | Backends must validate search constraints and return 64 | returns.result.Failure[stapi_fastapi.exceptions.ConstraintsException] if not valid. 65 | """ 66 | 67 | GetOpportunityCollection = Callable[ 68 | [ProductRouter, str, Request], 69 | Coroutine[Any, Any, ResultE[Maybe[OpportunityCollection]]], 70 | ] 71 | """ 72 | Type alias for an async function that retrieves the opportunity collection with 73 | `opportunity_collection_id`. 74 | 75 | The opportunity collection is generated by an asynchronous opportunity search. 76 | 77 | Args: 78 | product_router (ProductRouter): The product router. 79 | opportunity_collection_id (str): The ID of the opportunity collection. 80 | request (Request): FastAPI's Request object. 81 | 82 | Returns: 83 | - Should return returns.result.Success[returns.maybe.Some[OpportunityCollection]] if the opportunity collection is found. 84 | - Should return returns.result.Success[returns.maybe.Nothing] if the opportunity collection is not found or if access is denied. 85 | - Returning returns.result.Failure[Exception] will result in a 500. 86 | """ 87 | 88 | CreateOrder = Callable[ 89 | [ProductRouter, OrderPayload, Request], Coroutine[Any, Any, ResultE[Order]] 90 | ] 91 | """ 92 | Type alias for an async function that creates a new order. 93 | 94 | Args: 95 | product_router (ProductRouter): The product router. 96 | payload (OrderPayload): The order payload. 97 | request (Request): FastAPI's Request object. 98 | 99 | Returns: 100 | - Should return returns.result.Success[Order] 101 | - Returning returns.result.Failure[Exception] will result in a 500. 102 | 103 | Note: 104 | Backends must validate order payload and return 105 | returns.result.Failure[stapi_fastapi.exceptions.ConstraintsException] if not valid. 106 | """ 107 | -------------------------------------------------------------------------------- /src/stapi_fastapi/backends/root_backend.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Coroutine 2 | from typing import Any, Callable, TypeVar 3 | 4 | from fastapi import Request 5 | from returns.maybe import Maybe 6 | from returns.result import ResultE 7 | 8 | from stapi_fastapi.models.opportunity import OpportunitySearchRecord 9 | from stapi_fastapi.models.order import ( 10 | Order, 11 | OrderStatus, 12 | ) 13 | 14 | GetOrders = Callable[ 15 | [str | None, int, Request], 16 | Coroutine[Any, Any, ResultE[tuple[list[Order], Maybe[str]]]], 17 | ] 18 | """ 19 | Type alias for an async function that returns a list of existing Orders. 20 | 21 | Args: 22 | next (str | None): A pagination token. 23 | limit (int): The maximum number of orders to return in a page. 24 | request (Request): FastAPI's Request object. 25 | 26 | Returns: 27 | A tuple containing a list of orders and a pagination token. 28 | 29 | - Should return returns.result.Success[tuple[list[Order], returns.maybe.Some[str]]] if including a pagination token 30 | - Should return returns.result.Success[tuple[list[Order], returns.maybe.Nothing]] if not including a pagination token 31 | - Returning returns.result.Failure[Exception] will result in a 500. 32 | """ 33 | 34 | GetOrder = Callable[[str, Request], Coroutine[Any, Any, ResultE[Maybe[Order]]]] 35 | """ 36 | Type alias for an async function that gets details for the order with `order_id`. 37 | 38 | Args: 39 | order_id (str): The order ID. 40 | request (Request): FastAPI's Request object. 41 | 42 | Returns: 43 | - Should return returns.result.Success[returns.maybe.Some[Order]] if order is found. 44 | - Should return returns.result.Success[returns.maybe.Nothing] if the order is not found or if access is denied. 45 | - Returning returns.result.Failure[Exception] will result in a 500. 46 | """ 47 | 48 | 49 | T = TypeVar("T", bound=OrderStatus) 50 | 51 | 52 | GetOrderStatuses = Callable[ 53 | [str, str | None, int, Request], 54 | Coroutine[Any, Any, ResultE[Maybe[tuple[list[T], Maybe[str]]]]], 55 | ] 56 | """ 57 | Type alias for an async function that gets statuses for the order with `order_id`. 58 | 59 | Args: 60 | order_id (str): The order ID. 61 | next (str | None): A pagination token. 62 | limit (int): The maximum number of statuses to return in a page. 63 | request (Request): FastAPI's Request object. 64 | 65 | Returns: 66 | A tuple containing a list of order statuses and a pagination token. 67 | 68 | - Should return returns.result.Success[returns.maybe.Some[tuple[list[OrderStatus], returns.maybe.Some[str]]] if order is found and including a pagination token. 69 | - Should return returns.result.Success[returns.maybe.Some[tuple[list[OrderStatus], returns.maybe.Nothing]]] if order is found and not including a pagination token. 70 | - Should return returns.result.Success[returns.maybe.Nothing] if the order is not found or if access is denied. 71 | - Returning returns.result.Failure[Exception] will result in a 500. 72 | """ 73 | 74 | GetOpportunitySearchRecords = Callable[ 75 | [str | None, int, Request], 76 | Coroutine[Any, Any, ResultE[tuple[list[OpportunitySearchRecord], Maybe[str]]]], 77 | ] 78 | """ 79 | Type alias for an async function that gets OpportunitySearchRecords for all products. 80 | 81 | Args: 82 | request (Request): FastAPI's Request object. 83 | next (str | None): A pagination token. 84 | limit (int): The maximum number of search records to return in a page. 85 | 86 | Returns: 87 | - Should return returns.result.Success[tuple[list[OpportunitySearchRecord], returns.maybe.Some[str]]] if including a pagination token 88 | - Should return returns.result.Success[tuple[list[OpportunitySearchRecord], returns.maybe.Nothing]] if not including a pagination token 89 | - Returning returns.result.Failure[Exception] will result in a 500. 90 | """ 91 | 92 | GetOpportunitySearchRecord = Callable[ 93 | [str, Request], Coroutine[Any, Any, ResultE[Maybe[OpportunitySearchRecord]]] 94 | ] 95 | """ 96 | Type alias for an async function that gets the OpportunitySearchRecord with 97 | `search_record_id`. 98 | 99 | Args: 100 | search_record_id (str): The ID of the OpportunitySearchRecord. 101 | request (Request): FastAPI's Request object. 102 | 103 | Returns: 104 | - Should return returns.result.Success[returns.maybe.Some[OpportunitySearchRecord]] if the search record is found. 105 | - Should return returns.result.Success[returns.maybe.Nothing] if the search record is not found or if access is denied. 106 | - Returning returns.result.Failure[Exception] will result in a 500. 107 | """ 108 | -------------------------------------------------------------------------------- /tests/test_product.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import status 3 | from fastapi.testclient import TestClient 4 | 5 | from stapi_fastapi.models.product import Product 6 | 7 | from .shared import pagination_tester 8 | 9 | 10 | def test_products_response(stapi_client: TestClient): 11 | res = stapi_client.get("/products") 12 | 13 | assert res.status_code == status.HTTP_200_OK 14 | assert res.headers["Content-Type"] == "application/json" 15 | 16 | data = res.json() 17 | 18 | assert data["type"] == "ProductCollection" 19 | assert isinstance(data["products"], list) 20 | 21 | 22 | @pytest.mark.parametrize("product_id", ["test-spotlight"]) 23 | def test_product_response_self_link( 24 | product_id: str, 25 | stapi_client: TestClient, 26 | assert_link, 27 | ): 28 | res = stapi_client.get(f"/products/{product_id}") 29 | assert res.status_code == status.HTTP_200_OK 30 | assert res.headers["Content-Type"] == "application/json" 31 | 32 | body = res.json() 33 | 34 | url = "GET /products" 35 | assert_link(url, body, "self", f"/products/{product_id}") 36 | assert_link(url, body, "constraints", f"/products/{product_id}/constraints") 37 | assert_link( 38 | url, body, "order-parameters", f"/products/{product_id}/order-parameters" 39 | ) 40 | assert_link(url, body, "opportunities", f"/products/{product_id}/opportunities") 41 | assert_link( 42 | url, body, "create-order", f"/products/{product_id}/orders", method="POST" 43 | ) 44 | 45 | 46 | @pytest.mark.parametrize("product_id", ["test-spotlight"]) 47 | def test_product_constraints_response( 48 | product_id: str, 49 | stapi_client: TestClient, 50 | ): 51 | res = stapi_client.get(f"/products/{product_id}/constraints") 52 | assert res.status_code == status.HTTP_200_OK 53 | assert res.headers["Content-Type"] == "application/json" 54 | 55 | json_schema = res.json() 56 | assert "properties" in json_schema 57 | assert "off_nadir" in json_schema["properties"] 58 | 59 | 60 | @pytest.mark.parametrize("product_id", ["test-spotlight"]) 61 | def test_product_order_parameters_response( 62 | product_id: str, 63 | stapi_client: TestClient, 64 | ): 65 | res = stapi_client.get(f"/products/{product_id}/order-parameters") 66 | assert res.status_code == status.HTTP_200_OK 67 | assert res.headers["Content-Type"] == "application/json" 68 | 69 | json_schema = res.json() 70 | assert "properties" in json_schema 71 | assert "s3_path" in json_schema["properties"] 72 | 73 | 74 | @pytest.mark.parametrize("limit", [0, 1, 2, 4]) 75 | def test_get_products_pagination( 76 | limit: int, 77 | stapi_client: TestClient, 78 | mock_products: list[Product], 79 | ): 80 | expected_returns = [] 81 | if limit != 0: 82 | for product in mock_products: 83 | prod = product.model_dump(mode="json", by_alias=True) 84 | product_id = prod["id"] 85 | prod["links"] = [ 86 | { 87 | "href": f"http://stapiserver/products/{product_id}", 88 | "rel": "self", 89 | "type": "application/json", 90 | }, 91 | { 92 | "href": f"http://stapiserver/products/{product_id}/constraints", 93 | "rel": "constraints", 94 | "type": "application/json", 95 | }, 96 | { 97 | "href": f"http://stapiserver/products/{product_id}/order-parameters", 98 | "rel": "order-parameters", 99 | "type": "application/json", 100 | }, 101 | { 102 | "href": f"http://stapiserver/products/{product_id}/orders", 103 | "rel": "create-order", 104 | "type": "application/json", 105 | "method": "POST", 106 | }, 107 | { 108 | "href": f"http://stapiserver/products/{product_id}/opportunities", 109 | "rel": "opportunities", 110 | "type": "application/json", 111 | }, 112 | ] 113 | expected_returns.append(prod) 114 | 115 | pagination_tester( 116 | stapi_client=stapi_client, 117 | url="/products", 118 | method="GET", 119 | limit=limit, 120 | target="products", 121 | expected_returns=expected_returns, 122 | ) 123 | 124 | 125 | def test_token_not_found(stapi_client: TestClient) -> None: 126 | res = stapi_client.get("/products", params={"next": "a_token"}) 127 | assert res.status_code == status.HTTP_404_NOT_FOUND 128 | 129 | 130 | @pytest.mark.mock_products([]) 131 | def test_no_products(stapi_client: TestClient): 132 | res = stapi_client.get("/products") 133 | body = res.json() 134 | print("hold") 135 | assert res.status_code == status.HTTP_200_OK 136 | assert len(body["products"]) == 0 137 | -------------------------------------------------------------------------------- /src/stapi_fastapi/models/product.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import StrEnum 4 | from typing import TYPE_CHECKING, Literal, Optional, Self 5 | 6 | from pydantic import AnyHttpUrl, BaseModel, Field 7 | 8 | from stapi_fastapi.models.opportunity import OpportunityProperties 9 | from stapi_fastapi.models.order import OrderParameters 10 | from stapi_fastapi.models.shared import Link 11 | 12 | if TYPE_CHECKING: 13 | from stapi_fastapi.backends.product_backend import ( 14 | CreateOrder, 15 | GetOpportunityCollection, 16 | SearchOpportunities, 17 | SearchOpportunitiesAsync, 18 | ) 19 | 20 | 21 | type Constraints = BaseModel 22 | 23 | 24 | class ProviderRole(StrEnum): 25 | licensor = "licensor" 26 | producer = "producer" 27 | processor = "processor" 28 | host = "host" 29 | 30 | 31 | class Provider(BaseModel): 32 | name: str 33 | description: Optional[str] = None 34 | roles: list[ProviderRole] 35 | url: AnyHttpUrl 36 | 37 | # redefining init is a hack to get str type to validate for `url`, 38 | # as str is ultimately coerced into an AnyHttpUrl automatically anyway 39 | def __init__(self, url: AnyHttpUrl | str, **kwargs) -> None: 40 | super().__init__(url=url, **kwargs) 41 | 42 | 43 | class Product(BaseModel): 44 | type_: Literal["Product"] = Field(default="Product", alias="type") 45 | conformsTo: list[str] = Field(default_factory=list) 46 | id: str 47 | title: str = "" 48 | description: str = "" 49 | keywords: list[str] = Field(default_factory=list) 50 | license: str 51 | providers: list[Provider] = Field(default_factory=list) 52 | links: list[Link] = Field(default_factory=list) 53 | 54 | # we don't want to include these in the model fields 55 | _constraints: type[Constraints] 56 | _opportunity_properties: type[OpportunityProperties] 57 | _order_parameters: type[OrderParameters] 58 | _create_order: CreateOrder 59 | _search_opportunities: SearchOpportunities | None 60 | _search_opportunities_async: SearchOpportunitiesAsync | None 61 | _get_opportunity_collection: GetOpportunityCollection | None 62 | 63 | def __init__( 64 | self, 65 | *args, 66 | constraints: type[Constraints], 67 | opportunity_properties: type[OpportunityProperties], 68 | order_parameters: type[OrderParameters], 69 | create_order: CreateOrder, 70 | search_opportunities: SearchOpportunities | None = None, 71 | search_opportunities_async: SearchOpportunitiesAsync | None = None, 72 | get_opportunity_collection: GetOpportunityCollection | None = None, 73 | **kwargs, 74 | ) -> None: 75 | super().__init__(*args, **kwargs) 76 | 77 | if bool(search_opportunities_async) != bool(get_opportunity_collection): 78 | raise ValueError( 79 | "Both the `search_opportunities_async` and `get_opportunity_collection` " 80 | "arguments must be provided if either is provided" 81 | ) 82 | 83 | self._constraints = constraints 84 | self._opportunity_properties = opportunity_properties 85 | self._order_parameters = order_parameters 86 | self._create_order = create_order 87 | self._search_opportunities = search_opportunities 88 | self._search_opportunities_async = search_opportunities_async 89 | self._get_opportunity_collection = get_opportunity_collection 90 | 91 | @property 92 | def create_order(self) -> CreateOrder: 93 | return self._create_order 94 | 95 | @property 96 | def search_opportunities(self) -> SearchOpportunities: 97 | if not self._search_opportunities: 98 | raise AttributeError("This product does not support opportunity search") 99 | return self._search_opportunities 100 | 101 | @property 102 | def search_opportunities_async(self) -> SearchOpportunitiesAsync: 103 | if not self._search_opportunities_async: 104 | raise AttributeError( 105 | "This product does not support async opportunity search" 106 | ) 107 | return self._search_opportunities_async 108 | 109 | @property 110 | def get_opportunity_collection(self) -> GetOpportunityCollection: 111 | if not self._get_opportunity_collection: 112 | raise AttributeError( 113 | "This product does not support async opportunity search" 114 | ) 115 | return self._get_opportunity_collection 116 | 117 | @property 118 | def constraints(self) -> type[Constraints]: 119 | return self._constraints 120 | 121 | @property 122 | def opportunity_properties(self) -> type[OpportunityProperties]: 123 | return self._opportunity_properties 124 | 125 | @property 126 | def order_parameters(self) -> type[OrderParameters]: 127 | return self._order_parameters 128 | 129 | @property 130 | def supports_opportunity_search(self) -> bool: 131 | return self._search_opportunities is not None 132 | 133 | @property 134 | def supports_async_opportunity_search(self) -> bool: 135 | return ( 136 | self._search_opportunities_async is not None 137 | and self._get_opportunity_collection is not None 138 | ) 139 | 140 | def with_links(self, links: list[Link] | None = None) -> Self: 141 | if not links: 142 | return self 143 | 144 | new = self.model_copy(deep=True) 145 | new.links.extend(links) 146 | return new 147 | 148 | 149 | class ProductsCollection(BaseModel): 150 | type_: Literal["ProductCollection"] = Field( 151 | default="ProductCollection", alias="type" 152 | ) 153 | links: list[Link] = Field(default_factory=list) 154 | products: list[Product] 155 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## unreleased 9 | 10 | ### Fixed 11 | 12 | - Add parameter method as "POST" to create-order link 13 | 14 | ## Added 15 | 16 | - Add constants for route names to be used in link href generation 17 | 18 | ## [v0.6.0] - 2025-02-11 19 | 20 | ### Added 21 | 22 | - Added token-based pagination to `GET /orders`, `GET /products`, 23 | `GET /orders/{order_id}/statuses`, and `POST /products/{product_id}/opportunities`. 24 | - Optional and Extension STAPI Status Codes "scheduled", "held", "processing", "reserved", "tasked", 25 | and "user_canceled" 26 | - Asynchronous opportunity search. If the root router supports asynchronous opportunity 27 | search, all products must support it. If asynchronous opportunity search is 28 | supported, `POST` requests to the `/products/{productId}/opportunities` endpoint will 29 | default to asynchronous opportunity search unless synchronous search is also supported 30 | by the `product` and a `Prefer` header in the `POST` request is set to `wait`. 31 | - Added the `/products/{productId}/opportunities/` and `/searches/opportunities` 32 | endpoints to support asynchronous opportunity search. 33 | 34 | ### Changed 35 | 36 | - Replaced the root and product backend Protocol classes with Callable type aliases to 37 | enable future changes to make product opportunity searching, product ordering, and/or 38 | asynchronous (stateful) product opportunity searching optional. 39 | - Backend methods that support pagination now return tuples to include the pagination 40 | token. 41 | - Moved `OrderCollection` construction from the root backend to the `RootRouter` 42 | `get_orders` method. 43 | - Renamed `OpportunityRequest` to `OpportunityPayload` so that would not be confused as 44 | being a subclass of the Starlette/FastAPI Request class. 45 | 46 | ### Fixed 47 | 48 | - Opportunities Search result now has the search body in the `create-order` link. 49 | 50 | ## [v0.5.0] - 2025-01-08 51 | 52 | ### Added 53 | 54 | - Endpoint `/orders/{order_id}/statuses` supporting `GET` for retrieving statuses. The entity returned by this conforms 55 | to the change proposed in [stapi-spec#239](https://github.com/stapi-spec/stapi-spec/pull/239). 56 | - RootBackend has new method `get_order_statuses` 57 | - `*args`/`**kwargs` support in RootRouter's `add_product` allows to configure underlyinging ProductRouter 58 | 59 | ### Changed 60 | 61 | - OrderRequest renamed to OrderPayload 62 | 63 | ### Deprecated 64 | 65 | none 66 | 67 | ### Removed 68 | 69 | - Endpoint `/orders/{order_id}/statuses` supporting `POST` for updating current status was added and then 70 | removed prior to release 71 | - RootBackend method `set_order_status` was added and then removed 72 | 73 | ### Fixed 74 | 75 | - Exception logging 76 | 77 | ### Security 78 | 79 | none 80 | 81 | ## [v0.4.0] - 2024-12-11 82 | 83 | ### Added 84 | 85 | none 86 | 87 | ### Changed 88 | 89 | - The concepts of Opportunity search Constraint and Opportunity search result Opportunity Properties are now separate, 90 | recognizing that they have related attributes, but not neither the same attributes or the same values for those attributes. 91 | 92 | ### Deprecated 93 | 94 | none 95 | 96 | ### Removed 97 | 98 | none 99 | 100 | ### Fixed 101 | 102 | none 103 | 104 | ### Security 105 | 106 | none 107 | 108 | ## [v0.3.0] - 2024-12-6 109 | 110 | ### Added 111 | 112 | none 113 | 114 | ### Changed 115 | 116 | - OrderStatusCode and ProviderRole are now StrEnum instead of (str, Enum) 117 | - All types using `Result[A, Exception]` have been replace with the equivalent type `ResultE[A]` 118 | - Order and OrderCollection extend \_GeoJsonBase instead of Feature and FeatureCollection, to allow for tighter 119 | constraints on fields 120 | 121 | ### Deprecated 122 | 123 | none 124 | 125 | ### Removed 126 | 127 | none 128 | 129 | ### Fixed 130 | 131 | none 132 | 133 | ### Security 134 | 135 | none 136 | 137 | ## [v0.2.0] - 2024-11-23 138 | 139 | ### Added 140 | 141 | none 142 | 143 | ### Changed 144 | 145 | - RootBackend and ProductBackend protocols use `returns` library types Result and Maybe instead of 146 | raising exceptions. 147 | - Create Order endpoint from `.../order` to `.../orders` 148 | - Order field `id` must be a string, instead of previously allowing int. This is because while an 149 | order ID may an integral numeric value, it is not a "number" in the sense that math will be performed 150 | order ID values, so string represents this better. 151 | 152 | ### Deprecated 153 | 154 | none 155 | 156 | ### Removed 157 | 158 | none 159 | 160 | ### Fixed 161 | 162 | none 163 | 164 | ### Security 165 | 166 | none 167 | 168 | ## [v0.1.0] - 2024-11-15 169 | 170 | Initial release 171 | 172 | ### Added 173 | 174 | - Conformance endpoint `/conformance` and root body `conformsTo` attribute. 175 | - Field `product_id` to Opportunity and Order Properties 176 | - Endpoint /product/{productId}/order-parameters. 177 | - Links in Product entity to order-parameters and constraints endpoints for 178 | that product. 179 | - Add links `opportunities` and `create-order` to Product 180 | - Add link `create-order` to OpportunityCollection 181 | 182 | 183 | [v0.6.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.6.0 184 | [v0.5.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.5.0 185 | [v0.4.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.4.0 186 | [v0.3.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.3.0 187 | [v0.2.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.2.0 188 | [v0.1.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.1.0 189 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from collections.abc import AsyncIterator, Generator, Iterator 2 | from contextlib import asynccontextmanager 3 | from datetime import UTC, datetime, timedelta 4 | from typing import Any, Callable 5 | from urllib.parse import urljoin 6 | 7 | import pytest 8 | from fastapi import FastAPI 9 | from fastapi.testclient import TestClient 10 | 11 | from stapi_fastapi.models.conformance import ASYNC_OPPORTUNITIES, CORE, OPPORTUNITIES 12 | from stapi_fastapi.models.opportunity import ( 13 | Opportunity, 14 | ) 15 | from stapi_fastapi.models.product import ( 16 | Product, 17 | ) 18 | from stapi_fastapi.routers.root_router import RootRouter 19 | 20 | from .backends import ( 21 | mock_get_opportunity_search_record, 22 | mock_get_opportunity_search_records, 23 | mock_get_order, 24 | mock_get_order_statuses, 25 | mock_get_orders, 26 | ) 27 | from .shared import ( 28 | InMemoryOpportunityDB, 29 | InMemoryOrderDB, 30 | create_mock_opportunity, 31 | find_link, 32 | product_test_satellite_provider_sync_opportunity, 33 | product_test_spotlight_sync_opportunity, 34 | ) 35 | from .test_datetime_interval import rfc3339_strftime 36 | 37 | 38 | @pytest.fixture(scope="session") 39 | def base_url() -> Iterator[str]: 40 | yield "http://stapiserver" 41 | 42 | 43 | @pytest.fixture 44 | def mock_products(request) -> list[Product]: 45 | if request.node.get_closest_marker("mock_products") is not None: 46 | return request.node.get_closest_marker("mock_products").args[0] 47 | return [ 48 | product_test_spotlight_sync_opportunity, 49 | product_test_satellite_provider_sync_opportunity, 50 | ] 51 | 52 | 53 | @pytest.fixture 54 | def mock_opportunities() -> list[Opportunity]: 55 | return [create_mock_opportunity()] 56 | 57 | 58 | @pytest.fixture 59 | def stapi_client( 60 | mock_products: list[Product], 61 | base_url: str, 62 | mock_opportunities: list[Opportunity], 63 | ) -> Generator[TestClient, None, None]: 64 | @asynccontextmanager 65 | async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: 66 | try: 67 | yield { 68 | "_orders_db": InMemoryOrderDB(), 69 | "_opportunities": mock_opportunities, 70 | } 71 | finally: 72 | pass 73 | 74 | root_router = RootRouter( 75 | get_orders=mock_get_orders, 76 | get_order=mock_get_order, 77 | get_order_statuses=mock_get_order_statuses, 78 | conformances=[CORE], 79 | ) 80 | 81 | for mock_product in mock_products: 82 | root_router.add_product(mock_product) 83 | 84 | app = FastAPI(lifespan=lifespan) 85 | app.include_router(root_router, prefix="") 86 | 87 | with TestClient(app, base_url=f"{base_url}") as client: 88 | yield client 89 | 90 | 91 | @pytest.fixture 92 | def stapi_client_async_opportunity( 93 | mock_products: list[Product], 94 | base_url: str, 95 | mock_opportunities: list[Opportunity], 96 | ) -> Generator[TestClient, None, None]: 97 | @asynccontextmanager 98 | async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: 99 | try: 100 | yield { 101 | "_orders_db": InMemoryOrderDB(), 102 | "_opportunities_db": InMemoryOpportunityDB(), 103 | "_opportunities": mock_opportunities, 104 | } 105 | finally: 106 | pass 107 | 108 | root_router = RootRouter( 109 | get_orders=mock_get_orders, 110 | get_order=mock_get_order, 111 | get_order_statuses=mock_get_order_statuses, 112 | get_opportunity_search_records=mock_get_opportunity_search_records, 113 | get_opportunity_search_record=mock_get_opportunity_search_record, 114 | conformances=[CORE, OPPORTUNITIES, ASYNC_OPPORTUNITIES], 115 | ) 116 | 117 | for mock_product in mock_products: 118 | root_router.add_product(mock_product) 119 | 120 | app = FastAPI(lifespan=lifespan) 121 | app.include_router(root_router, prefix="") 122 | 123 | with TestClient(app, base_url=f"{base_url}") as client: 124 | yield client 125 | 126 | 127 | @pytest.fixture(scope="session") 128 | def url_for(base_url: str) -> Iterator[Callable[[str], str]]: 129 | def with_trailing_slash(value: str) -> str: 130 | return value if value.endswith("/") else f"{value}/" 131 | 132 | def url_for(value: str) -> str: 133 | return urljoin(with_trailing_slash(base_url), f"./{value.lstrip('/')}") 134 | 135 | yield url_for 136 | 137 | 138 | @pytest.fixture 139 | def assert_link(url_for) -> Callable: 140 | def _assert_link( 141 | req: str, 142 | body: dict[str, Any], 143 | rel: str, 144 | path: str, 145 | media_type: str = "application/json", 146 | method: str | None = None, 147 | ): 148 | link = find_link(body["links"], rel) 149 | assert link, f"{req} Link[rel={rel}] should exist" 150 | assert link["type"] == media_type 151 | assert link["href"] == url_for(path) 152 | if method: 153 | assert link["method"] == method 154 | 155 | return _assert_link 156 | 157 | 158 | @pytest.fixture 159 | def limit() -> int: 160 | return 10 161 | 162 | 163 | @pytest.fixture 164 | def opportunity_search(limit) -> dict[str, Any]: 165 | now = datetime.now(UTC) 166 | end = now + timedelta(days=5) 167 | format = "%Y-%m-%dT%H:%M:%S.%f%z" 168 | start_string = rfc3339_strftime(now, format) 169 | end_string = rfc3339_strftime(end, format) 170 | 171 | return { 172 | "geometry": { 173 | "type": "Point", 174 | "coordinates": [0, 0], 175 | }, 176 | "datetime": f"{start_string}/{end_string}", 177 | "filter": { 178 | "op": "and", 179 | "args": [ 180 | {"op": ">", "args": [{"property": "off_nadir"}, 0]}, 181 | {"op": "<", "args": [{"property": "off_nadir"}, 45]}, 182 | ], 183 | }, 184 | "limit": limit, 185 | } 186 | -------------------------------------------------------------------------------- /adrs/constraints.md: -------------------------------------------------------------------------------- 1 | # Constraints and Opportunity Properties 2 | 3 | Previously, the Constraints and Opportunity Properties were the same 4 | concept/representation. However, these represent distinct but related 5 | attributes. Constraints represents the terms that can be used in the filter sent 6 | to the Opportunities Search and Order Create endpoints. These are frequently the 7 | same or related values that will be part of the STAC Items that are used to 8 | fulfill an eventual Order. Opportunity Properties represent the expected range 9 | of values that these STAC Items are expected to have. An opportunity is a 10 | prediction about the future, and as such, the values for the Opportunity are 11 | fuzzy. For example, the sun azimuth angle will (likely) be within a predictable 12 | range of values, but the exact value will not be known until after the capture 13 | occurs. Therefore, it is necessary to describe the Opportunity in a way that 14 | describes these ranges. 15 | 16 | For example, for the concept of "off_nadir": 17 | 18 | The Constraint will be a term "off_nadir" that can be a value 0 to 45. 19 | This is used in a CQL2 filter to the Opportunities Search endpoint to restrict the allowable values from 0 to 15 20 | The Opportunity that is returned from Search has an Opportunity Property 21 | "off_nadir" with a description that the value of this field in the resulting 22 | STAC Items will be between 4 and 8, which falls within the filter restriction of 0-15. 23 | An Order is created with the original filter and other fields. 24 | The Order is fulfilled with a STAC Item that has an off_nadir value of 4.8. 25 | 26 | As of Dec 2024, the STAPI spec says only that the Opportunity Properties must 27 | have a datetime interval field `datetime` and a `product_id` field. The 28 | remainder of the Opportunity description proprietary is up to the provider to 29 | define. The example given this this repo for `off_nadir` is of a custom format 30 | with a "minimum" and "maximum" field describing the limits. 31 | 32 | ## JSON Schema 33 | 34 | Another option would be to use either a full JSON Schema definition for an 35 | attribute value in the properties (e.g., `schema`) or individual attribute 36 | definitions for the properties values. This option should be investigated 37 | further in the future. 38 | 39 | JSON Schema is a well-defined specification language that can support this type 40 | of data description. It is already used as the language for OGC API Queryables 41 | to define the constraints on various terms that may be used in CQL2 expressions, 42 | and likewise within STAPI for the Constraints that are used in Opportunity 43 | Search and the Order Parameters that are set on an order. The use of JSON Schema 44 | for Constraints (as with Queryables) is not to specify validation for a JSON 45 | document, but rather to well-define a set of typed and otherwise-constrained 46 | terms. Similarly, JSON Schema would be used for the Opportunity to define the 47 | predicted ranges of properties within the Opportunity that is bound to fulfill 48 | an Order. 49 | 50 | The geometry is not one of the fields that will be expressed as a schema 51 | constraint, since this is part of the Opportunity/Item/Feature top-level. The 52 | Opportunity geometry will express both uncertainty about the actual capture area 53 | and a “maximum extent” of capture, e.g., a small area within a larger data strip 54 | – this is intentionally vague so it can be used to express whatever semantics 55 | the provider wants. 56 | 57 | The ranges of predicted Opportunity values can be expressed using JSON in the following way: 58 | 59 | - numeric value - number with const, enum, or minimum/maximum/exclusiveMinimum/exclusiveMaximum 60 | - string value - string with const or enum 61 | - datetime - type string using format date-time. The limitation wit this is that 62 | these values are not treated with JSON Schema as temporal, but rather a string 63 | pattern. As such, there is no formal way to define a temporal interval that the 64 | instance value must be within. Instead, we will repurpose the description field 65 | as a datetime interval in the same format as a search datetime field, e.g., 66 | 2024-01-01T00:00:00Z/2024-01-07T00:00:00Z. Optionally, the pattern field can be 67 | defined if the valid datetime values also match a regular expression, e.g., 68 | 2024-01-0[123456]T.*, which while not as useful semantically as the description 69 | interval does provide a formal validation of the resulting object, which waving 70 | hand might be useful in some way waving hand. 71 | 72 | ```json 73 | { 74 | "$schema": "https://json-schema.org/draft/2020-12/schema", 75 | "$id": "schema.json", 76 | "type": "object", 77 | "properties": { 78 | "datetime": { 79 | "title": "Datetime", 80 | "type": "string", 81 | "format": "date-time", 82 | "description": "2024-01-01T00:00:00Z/2024-01-07T00:00:00Z", 83 | "pattern": "2024-01-0[123456]T.*" 84 | }, 85 | "sensor_type": { 86 | "title": "Sensor Type", 87 | "type": "string", 88 | "const": "2" 89 | }, 90 | "craft_id": { 91 | "title": "Spacecraft ID", 92 | "type": "string", 93 | "enum": [ 94 | "7", 95 | "8" 96 | ] 97 | }, 98 | "view:sun_elevation": { 99 | "title": "View:Sun Elevation", 100 | "type": "number", 101 | "minimum": 30.0, 102 | "maximum": 35.0 103 | }, 104 | "view:azimuth": { 105 | "title": "View:Azimuth", 106 | "type": "number", 107 | "exclusiveMinimum": 104.0, 108 | "exclusiveMaximum": 115.0 109 | }, 110 | "view:off_nadir": { 111 | "title": "View:Off Nadir", 112 | "type": "number", 113 | "minimum": 0.0, 114 | "maximum": 9.0 115 | }, 116 | "eo:cloud_cover": { 117 | "title": "Eo:Cloud Cover", 118 | "type": "number", 119 | "minimum": 5.0, 120 | "maximum": 15.0 121 | } 122 | } 123 | } 124 | ``` 125 | 126 | The Item that fulfills and Order placed on this Opportunity might be like: 127 | 128 | ```json 129 | { 130 | "type": "Feature", 131 | ... 132 | "properties": { 133 | "datetime": "2024-01-01T00:00:00Z", 134 | "sensor_type": "2", 135 | "craft_id": "7", 136 | "view:sun_elevation": 30.0, 137 | "view:azimuth": 105.0, 138 | "view:off_nadir": 9.0, 139 | "eo:cloud_cover": 10.0 140 | } 141 | } 142 | ``` 143 | -------------------------------------------------------------------------------- /tests/backends.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from uuid import uuid4 3 | 4 | from fastapi import Request 5 | from returns.maybe import Maybe, Nothing, Some 6 | from returns.result import Failure, ResultE, Success 7 | 8 | from stapi_fastapi.models.opportunity import ( 9 | Opportunity, 10 | OpportunityCollection, 11 | OpportunityPayload, 12 | OpportunitySearchRecord, 13 | OpportunitySearchStatus, 14 | OpportunitySearchStatusCode, 15 | ) 16 | from stapi_fastapi.models.order import ( 17 | Order, 18 | OrderPayload, 19 | OrderProperties, 20 | OrderSearchParameters, 21 | OrderStatus, 22 | OrderStatusCode, 23 | ) 24 | from stapi_fastapi.routers.product_router import ProductRouter 25 | 26 | 27 | async def mock_get_orders( 28 | next: str | None, limit: int, request: Request 29 | ) -> ResultE[tuple[list[Order], Maybe[str]]]: 30 | """ 31 | Return orders from backend. Handle pagination/limit if applicable 32 | """ 33 | try: 34 | start = 0 35 | limit = min(limit, 100) 36 | order_ids = [*request.state._orders_db._orders.keys()] 37 | 38 | if next: 39 | start = order_ids.index(next) 40 | end = start + limit 41 | ids = order_ids[start:end] 42 | orders = [request.state._orders_db.get_order(order_id) for order_id in ids] 43 | 44 | if end > 0 and end < len(order_ids): 45 | return Success( 46 | (orders, Some(request.state._orders_db._orders[order_ids[end]].id)) 47 | ) 48 | return Success((orders, Nothing)) 49 | except Exception as e: 50 | return Failure(e) 51 | 52 | 53 | async def mock_get_order(order_id: str, request: Request) -> ResultE[Maybe[Order]]: 54 | """ 55 | Show details for order with `order_id`. 56 | """ 57 | try: 58 | return Success( 59 | Maybe.from_optional(request.state._orders_db.get_order(order_id)) 60 | ) 61 | except Exception as e: 62 | return Failure(e) 63 | 64 | 65 | async def mock_get_order_statuses( 66 | order_id: str, next: str | None, limit: int, request: Request 67 | ) -> ResultE[Maybe[tuple[list[OrderStatus], Maybe[str]]]]: 68 | try: 69 | start = 0 70 | limit = min(limit, 100) 71 | statuses = request.state._orders_db.get_order_statuses(order_id) 72 | if statuses is None: 73 | return Success(Nothing) 74 | 75 | if next: 76 | start = int(next) 77 | end = start + limit 78 | stati = statuses[start:end] 79 | 80 | if end > 0 and end < len(statuses): 81 | return Success(Some((stati, Some(str(end))))) 82 | return Success(Some((stati, Nothing))) 83 | except Exception as e: 84 | return Failure(e) 85 | 86 | 87 | async def mock_create_order( 88 | product_router: ProductRouter, payload: OrderPayload, request: Request 89 | ) -> ResultE[Order]: 90 | """ 91 | Create a new order. 92 | """ 93 | try: 94 | status = OrderStatus( 95 | timestamp=datetime.now(timezone.utc), 96 | status_code=OrderStatusCode.received, 97 | ) 98 | order = Order( 99 | id=str(uuid4()), 100 | geometry=payload.geometry, 101 | properties=OrderProperties( 102 | product_id=product_router.product.id, 103 | created=datetime.now(timezone.utc), 104 | status=status, 105 | search_parameters=OrderSearchParameters( 106 | geometry=payload.geometry, 107 | datetime=payload.datetime, 108 | filter=payload.filter, 109 | ), 110 | order_parameters=payload.order_parameters.model_dump(), 111 | opportunity_properties={ 112 | "datetime": "2024-01-29T12:00:00Z/2024-01-30T12:00:00Z", 113 | "off_nadir": 10, 114 | }, 115 | ), 116 | links=[], 117 | ) 118 | 119 | request.state._orders_db.put_order(order) 120 | request.state._orders_db.put_order_status(order.id, status) 121 | return Success(order) 122 | except Exception as e: 123 | return Failure(e) 124 | 125 | 126 | async def mock_search_opportunities( 127 | product_router: ProductRouter, 128 | search: OpportunityPayload, 129 | next: str | None, 130 | limit: int, 131 | request: Request, 132 | ) -> ResultE[tuple[list[Opportunity], Maybe[str]]]: 133 | try: 134 | start = 0 135 | limit = min(limit, 100) 136 | if next: 137 | start = int(next) 138 | end = start + limit 139 | opportunities = [ 140 | o.model_copy(update=search.model_dump()) 141 | for o in request.state._opportunities[start:end] 142 | ] 143 | if end > 0 and end < len(request.state._opportunities): 144 | return Success((opportunities, Some(str(end)))) 145 | return Success((opportunities, Nothing)) 146 | except Exception as e: 147 | return Failure(e) 148 | 149 | 150 | async def mock_search_opportunities_async( 151 | product_router: ProductRouter, 152 | search: OpportunityPayload, 153 | request: Request, 154 | ) -> ResultE[OpportunitySearchRecord]: 155 | try: 156 | received_status = OpportunitySearchStatus( 157 | timestamp=datetime.now(timezone.utc), 158 | status_code=OpportunitySearchStatusCode.received, 159 | ) 160 | search_record = OpportunitySearchRecord( 161 | id=str(uuid4()), 162 | product_id=product_router.product.id, 163 | opportunity_request=search, 164 | status=received_status, 165 | links=[], 166 | ) 167 | request.state._opportunities_db.put_search_record(search_record) 168 | return Success(search_record) 169 | except Exception as e: 170 | return Failure(e) 171 | 172 | 173 | async def mock_get_opportunity_collection( 174 | product_router: ProductRouter, opportunity_collection_id: str, request: Request 175 | ) -> ResultE[Maybe[OpportunityCollection]]: 176 | try: 177 | return Success( 178 | Maybe.from_optional( 179 | request.state._opportunities_db.get_opportunity_collection( 180 | opportunity_collection_id 181 | ) 182 | ) 183 | ) 184 | except Exception as e: 185 | return Failure(e) 186 | 187 | 188 | async def mock_get_opportunity_search_records( 189 | next: str | None, 190 | limit: int, 191 | request: Request, 192 | ) -> ResultE[tuple[list[OpportunitySearchRecord], Maybe[str]]]: 193 | try: 194 | start = 0 195 | limit = min(limit, 100) 196 | search_records = request.state._opportunities_db.get_search_records() 197 | 198 | if next: 199 | start = int(next) 200 | end = start + limit 201 | page = search_records[start:end] 202 | 203 | if end > 0 and end < len(search_records): 204 | return Success((page, Some(str(end)))) 205 | return Success((page, Nothing)) 206 | except Exception as e: 207 | return Failure(e) 208 | 209 | 210 | async def mock_get_opportunity_search_record( 211 | search_record_id: str, request: Request 212 | ) -> ResultE[Maybe[OpportunitySearchRecord]]: 213 | try: 214 | return Success( 215 | Maybe.from_optional( 216 | request.state._opportunities_db.get_search_record(search_record_id) 217 | ) 218 | ) 219 | except Exception as e: 220 | return Failure(e) 221 | -------------------------------------------------------------------------------- /tests/test_order.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime, timedelta, timezone 2 | 3 | import pytest 4 | from fastapi import status 5 | from fastapi.testclient import TestClient 6 | from geojson_pydantic import Point 7 | from geojson_pydantic.types import Position2D 8 | from httpx import Response 9 | 10 | from stapi_fastapi.models.order import Order, OrderPayload, OrderStatus, OrderStatusCode 11 | 12 | from .shared import MyOrderParameters, find_link, pagination_tester 13 | 14 | NOW = datetime.now(UTC) 15 | START = NOW 16 | END = START + timedelta(days=5) 17 | 18 | 19 | def test_empty_order(stapi_client: TestClient): 20 | res = stapi_client.get("/orders") 21 | default_orders = {"type": "FeatureCollection", "features": [], "links": []} 22 | assert res.status_code == status.HTTP_200_OK 23 | assert res.headers["Content-Type"] == "application/geo+json" 24 | assert res.json() == default_orders 25 | 26 | 27 | @pytest.fixture 28 | def create_order_payloads() -> list[OrderPayload]: 29 | datetimes = [ 30 | ("2024-10-09T18:55:33Z", "2024-10-12T18:55:33Z"), 31 | ("2024-10-15T18:55:33Z", "2024-10-18T18:55:33Z"), 32 | ("2024-10-20T18:55:33Z", "2024-10-23T18:55:33Z"), 33 | ] 34 | payloads = [] 35 | for start, end in datetimes: 36 | payload = OrderPayload( 37 | geometry=Point( 38 | type="Point", coordinates=Position2D(longitude=14.4, latitude=56.5) 39 | ), 40 | datetime=( 41 | datetime.fromisoformat(start), 42 | datetime.fromisoformat(end), 43 | ), 44 | filter=None, 45 | order_parameters=MyOrderParameters(s3_path="s3://my-bucket"), 46 | ) 47 | payloads.append(payload) 48 | return payloads 49 | 50 | 51 | @pytest.fixture 52 | def new_order_response( 53 | product_id: str, 54 | stapi_client: TestClient, 55 | create_order_payloads: list[OrderPayload], 56 | ) -> Response: 57 | res = stapi_client.post( 58 | f"products/{product_id}/orders", 59 | json=create_order_payloads[0].model_dump(), 60 | ) 61 | 62 | assert res.status_code == status.HTTP_201_CREATED, res.text 63 | assert res.headers["Content-Type"] == "application/geo+json" 64 | return res 65 | 66 | 67 | @pytest.mark.parametrize("product_id", ["test-spotlight"]) 68 | def test_new_order_location_header_matches_self_link( 69 | new_order_response: Response, 70 | ) -> None: 71 | order = new_order_response.json() 72 | link = find_link(order["links"], "self") 73 | assert link 74 | assert new_order_response.headers["Location"] == str(link["href"]) 75 | 76 | 77 | @pytest.mark.parametrize("product_id", ["test-spotlight"]) 78 | def test_new_order_links(new_order_response: Response, assert_link) -> None: 79 | order = new_order_response.json() 80 | assert_link( 81 | f"GET /orders/{order['id']}", 82 | order, 83 | "monitor", 84 | f"/orders/{order['id']}/statuses", 85 | ) 86 | 87 | assert_link( 88 | f"GET /orders/{order['id']}", 89 | order, 90 | "self", 91 | f"/orders/{order['id']}", 92 | media_type="application/geo+json", 93 | ) 94 | 95 | 96 | @pytest.fixture 97 | def get_order_response( 98 | stapi_client: TestClient, new_order_response: Response 99 | ) -> Response: 100 | order_id = new_order_response.json()["id"] 101 | 102 | res = stapi_client.get(f"/orders/{order_id}") 103 | assert res.status_code == status.HTTP_200_OK 104 | assert res.headers["Content-Type"] == "application/geo+json" 105 | return res 106 | 107 | 108 | @pytest.mark.parametrize("product_id", ["test-spotlight"]) 109 | def test_get_order_properties( 110 | get_order_response: Response, create_order_payloads 111 | ) -> None: 112 | order = get_order_response.json() 113 | 114 | assert order["geometry"] == { 115 | "type": "Point", 116 | "coordinates": list(create_order_payloads[0].geometry.coordinates), 117 | } 118 | 119 | assert order["properties"]["search_parameters"]["geometry"] == { 120 | "type": "Point", 121 | "coordinates": list(create_order_payloads[0].geometry.coordinates), 122 | } 123 | 124 | assert ( 125 | order["properties"]["search_parameters"]["datetime"] 126 | == create_order_payloads[0].model_dump()["datetime"] 127 | ) 128 | 129 | 130 | @pytest.mark.parametrize("product_id", ["test-spotlight"]) 131 | def test_order_status_after_create( 132 | get_order_response: Response, stapi_client: TestClient, assert_link 133 | ) -> None: 134 | body = get_order_response.json() 135 | assert_link( 136 | f"GET /orders/{body['id']}", body, "monitor", f"/orders/{body['id']}/statuses" 137 | ) 138 | link = find_link(body["links"], "monitor") 139 | assert link is not None 140 | 141 | res = stapi_client.get(link["href"]) 142 | assert res.status_code == status.HTTP_200_OK 143 | assert res.headers["Content-Type"] == "application/json" 144 | assert len(res.json()["statuses"]) == 1 145 | 146 | 147 | @pytest.fixture 148 | def setup_orders_pagination( 149 | stapi_client: TestClient, create_order_payloads 150 | ) -> list[Order]: 151 | product_id = "test-spotlight" 152 | orders = [] 153 | for order in create_order_payloads: 154 | res = stapi_client.post( 155 | f"products/{product_id}/orders", 156 | json=order.model_dump(), 157 | ) 158 | body = res.json() 159 | orders.append(body) 160 | 161 | assert res.status_code == status.HTTP_201_CREATED, res.text 162 | assert res.headers["Content-Type"] == "application/geo+json" 163 | 164 | return orders 165 | 166 | 167 | @pytest.mark.parametrize("limit", [0, 1, 2, 4]) 168 | def test_get_orders_pagination( 169 | limit, setup_orders_pagination, create_order_payloads, stapi_client: TestClient 170 | ) -> None: 171 | expected_returns = [] 172 | if limit > 0: 173 | expected_returns = setup_orders_pagination 174 | 175 | pagination_tester( 176 | stapi_client=stapi_client, 177 | url="/orders", 178 | method="GET", 179 | limit=limit, 180 | target="features", 181 | expected_returns=expected_returns, 182 | ) 183 | 184 | 185 | def test_token_not_found(stapi_client: TestClient) -> None: 186 | res = stapi_client.get("/orders", params={"next": "a_token"}) 187 | assert res.status_code == status.HTTP_404_NOT_FOUND 188 | 189 | 190 | @pytest.fixture 191 | def order_statuses() -> dict[str, list[OrderStatus]]: 192 | statuses = { 193 | "test_order_id": [ 194 | OrderStatus( 195 | timestamp=datetime(2025, 1, 14, 2, 21, 48, 466726, tzinfo=timezone.utc), 196 | status_code=OrderStatusCode.received, 197 | links=[], 198 | ), 199 | OrderStatus( 200 | timestamp=datetime(2025, 1, 15, 5, 20, 48, 466726, tzinfo=timezone.utc), 201 | status_code=OrderStatusCode.accepted, 202 | links=[], 203 | ), 204 | OrderStatus( 205 | timestamp=datetime( 206 | 2025, 1, 16, 10, 15, 32, 466726, tzinfo=timezone.utc 207 | ), 208 | status_code=OrderStatusCode.completed, 209 | links=[], 210 | ), 211 | ] 212 | } 213 | return statuses 214 | 215 | 216 | @pytest.mark.parametrize("limit", [0, 1, 2, 4]) 217 | def test_get_order_status_pagination( 218 | limit: int, 219 | stapi_client: TestClient, 220 | order_statuses: dict[str, list[OrderStatus]], 221 | ) -> None: 222 | for id, statuses in order_statuses.items(): 223 | for s in statuses: 224 | stapi_client.app_state["_orders_db"].put_order_status(id, s) 225 | 226 | order_id = "test_order_id" 227 | expected_returns = [] 228 | if limit != 0: 229 | expected_returns = [x.model_dump(mode="json") for x in order_statuses[order_id]] 230 | 231 | pagination_tester( 232 | stapi_client=stapi_client, 233 | url=f"/orders/{order_id}/statuses", 234 | method="GET", 235 | limit=limit, 236 | target="statuses", 237 | expected_returns=expected_returns, 238 | ) 239 | 240 | 241 | def test_get_order_statuses_bad_token( 242 | stapi_client: TestClient, 243 | order_statuses: dict[str, list[OrderStatus]], 244 | limit: int = 2, 245 | ) -> None: 246 | stapi_client.app_state["_orders_db"]._statuses = order_statuses 247 | 248 | order_id = "non_existing_order_id" 249 | res = stapi_client.get(f"/orders/{order_id}/statuses") 250 | assert res.status_code == status.HTTP_404_NOT_FOUND 251 | -------------------------------------------------------------------------------- /tests/shared.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from copy import deepcopy 3 | from datetime import datetime, timedelta, timezone 4 | from typing import Any, Literal, Self 5 | from urllib.parse import parse_qs, urlparse 6 | from uuid import uuid4 7 | 8 | from fastapi import status 9 | from fastapi.testclient import TestClient 10 | from geojson_pydantic import Point 11 | from geojson_pydantic.types import Position2D 12 | from httpx import Response 13 | from pydantic import BaseModel, Field, model_validator 14 | from pytest import fail 15 | 16 | from stapi_fastapi.models.opportunity import ( 17 | Opportunity, 18 | OpportunityCollection, 19 | OpportunityProperties, 20 | OpportunitySearchRecord, 21 | ) 22 | from stapi_fastapi.models.order import ( 23 | Order, 24 | OrderParameters, 25 | OrderStatus, 26 | ) 27 | from stapi_fastapi.models.product import ( 28 | Product, 29 | Provider, 30 | ProviderRole, 31 | ) 32 | 33 | from .backends import ( 34 | mock_create_order, 35 | mock_get_opportunity_collection, 36 | mock_search_opportunities, 37 | mock_search_opportunities_async, 38 | ) 39 | 40 | type link_dict = dict[str, Any] 41 | 42 | 43 | def find_link(links: list[link_dict], rel: str) -> link_dict | None: 44 | return next((link for link in links if link["rel"] == rel), None) 45 | 46 | 47 | class InMemoryOrderDB: 48 | def __init__(self) -> None: 49 | self._orders: dict[str, Order] = {} 50 | self._statuses: dict[str, list[OrderStatus]] = defaultdict(list) 51 | 52 | def get_order(self, order_id: str) -> Order | None: 53 | return deepcopy(self._orders.get(order_id)) 54 | 55 | def get_orders(self) -> list[Order]: 56 | return deepcopy(list(self._orders.values())) 57 | 58 | def put_order(self, order: Order) -> None: 59 | self._orders[order.id] = deepcopy(order) 60 | 61 | def get_order_statuses(self, order_id: str) -> list[OrderStatus] | None: 62 | return deepcopy(self._statuses.get(order_id)) 63 | 64 | def put_order_status(self, order_id: str, status: OrderStatus) -> None: 65 | self._statuses[order_id].append(deepcopy(status)) 66 | 67 | 68 | class InMemoryOpportunityDB: 69 | def __init__(self) -> None: 70 | self._search_records: dict[str, OpportunitySearchRecord] = {} 71 | self._collections: dict[str, OpportunityCollection] = {} 72 | 73 | def get_search_record(self, search_id: str) -> OpportunitySearchRecord | None: 74 | return deepcopy(self._search_records.get(search_id)) 75 | 76 | def get_search_records(self) -> list[OpportunitySearchRecord]: 77 | return deepcopy(list(self._search_records.values())) 78 | 79 | def put_search_record(self, search_record: OpportunitySearchRecord) -> None: 80 | self._search_records[search_record.id] = deepcopy(search_record) 81 | 82 | def get_opportunity_collection(self, collection_id) -> OpportunityCollection | None: 83 | return deepcopy(self._collections.get(collection_id)) 84 | 85 | def put_opportunity_collection(self, collection: OpportunityCollection) -> None: 86 | if collection.id is None: 87 | raise ValueError("collection must have an id") 88 | self._collections[collection.id] = deepcopy(collection) 89 | 90 | 91 | class MyProductConstraints(BaseModel): 92 | off_nadir: int 93 | 94 | 95 | class OffNadirRange(BaseModel): 96 | minimum: int = Field(ge=0, le=45) 97 | maximum: int = Field(ge=0, le=45) 98 | 99 | @model_validator(mode="after") 100 | def validate_range(self) -> Self: 101 | if self.minimum > self.maximum: 102 | raise ValueError("range minimum cannot be greater than maximum") 103 | return self 104 | 105 | 106 | class MyOpportunityProperties(OpportunityProperties): 107 | off_nadir: OffNadirRange 108 | vehicle_id: list[Literal[1, 2, 5, 7, 8]] 109 | platform: Literal["platform_id"] 110 | 111 | 112 | class MyOrderParameters(OrderParameters): 113 | s3_path: str | None = None 114 | 115 | 116 | provider = Provider( 117 | name="Test Provider", 118 | description="A provider for Test data", 119 | roles=[ProviderRole.producer], # Example role 120 | url="https://test-provider.example.com", # Must be a valid URL 121 | ) 122 | 123 | product_test_spotlight = Product( 124 | id="test-spotlight", 125 | title="Test Spotlight Product", 126 | description="Test product for test spotlight", 127 | license="CC-BY-4.0", 128 | keywords=["test", "satellite"], 129 | providers=[provider], 130 | links=[], 131 | create_order=mock_create_order, 132 | search_opportunities=None, 133 | search_opportunities_async=None, 134 | get_opportunity_collection=None, 135 | constraints=MyProductConstraints, 136 | opportunity_properties=MyOpportunityProperties, 137 | order_parameters=MyOrderParameters, 138 | ) 139 | 140 | product_test_spotlight_sync_opportunity = Product( 141 | id="test-spotlight", 142 | title="Test Spotlight Product", 143 | description="Test product for test spotlight", 144 | license="CC-BY-4.0", 145 | keywords=["test", "satellite"], 146 | providers=[provider], 147 | links=[], 148 | create_order=mock_create_order, 149 | search_opportunities=mock_search_opportunities, 150 | search_opportunities_async=None, 151 | get_opportunity_collection=None, 152 | constraints=MyProductConstraints, 153 | opportunity_properties=MyOpportunityProperties, 154 | order_parameters=MyOrderParameters, 155 | ) 156 | 157 | 158 | product_test_spotlight_async_opportunity = Product( 159 | id="test-spotlight", 160 | title="Test Spotlight Product", 161 | description="Test product for test spotlight", 162 | license="CC-BY-4.0", 163 | keywords=["test", "satellite"], 164 | providers=[provider], 165 | links=[], 166 | create_order=mock_create_order, 167 | search_opportunities=None, 168 | search_opportunities_async=mock_search_opportunities_async, 169 | get_opportunity_collection=mock_get_opportunity_collection, 170 | constraints=MyProductConstraints, 171 | opportunity_properties=MyOpportunityProperties, 172 | order_parameters=MyOrderParameters, 173 | ) 174 | 175 | product_test_spotlight_sync_async_opportunity = Product( 176 | id="test-spotlight", 177 | title="Test Spotlight Product", 178 | description="Test product for test spotlight", 179 | license="CC-BY-4.0", 180 | keywords=["test", "satellite"], 181 | providers=[provider], 182 | links=[], 183 | create_order=mock_create_order, 184 | search_opportunities=mock_search_opportunities, 185 | search_opportunities_async=mock_search_opportunities_async, 186 | get_opportunity_collection=mock_get_opportunity_collection, 187 | constraints=MyProductConstraints, 188 | opportunity_properties=MyOpportunityProperties, 189 | order_parameters=MyOrderParameters, 190 | ) 191 | 192 | product_test_satellite_provider_sync_opportunity = Product( 193 | id="test-satellite-provider", 194 | title="Satellite Product", 195 | description="A product by a satellite provider", 196 | license="CC-BY-4.0", 197 | keywords=["test", "satellite", "provider"], 198 | providers=[provider], 199 | links=[], 200 | create_order=mock_create_order, 201 | search_opportunities=mock_search_opportunities, 202 | search_opportunities_async=None, 203 | get_opportunity_collection=None, 204 | constraints=MyProductConstraints, 205 | opportunity_properties=MyOpportunityProperties, 206 | order_parameters=MyOrderParameters, 207 | ) 208 | 209 | 210 | def create_mock_opportunity() -> Opportunity: 211 | now = datetime.now(timezone.utc) # Use timezone-aware datetime 212 | start = now 213 | end = start + timedelta(days=5) 214 | 215 | # Create a list of mock opportunities for the given product 216 | return Opportunity( 217 | id=str(uuid4()), 218 | type="Feature", 219 | geometry=Point( 220 | type="Point", 221 | coordinates=Position2D(longitude=0.0, latitude=0.0), 222 | ), 223 | properties=MyOpportunityProperties( 224 | product_id="xyz123", 225 | datetime=(start, end), 226 | off_nadir=OffNadirRange(minimum=20, maximum=22), 227 | vehicle_id=[1], 228 | platform="platform_id", 229 | other_thing="abcd1234", # type: ignore 230 | ), 231 | ) 232 | 233 | 234 | def pagination_tester( 235 | stapi_client: TestClient, 236 | url: str, 237 | method: str, 238 | limit: int, 239 | target: str, 240 | expected_returns: list, 241 | body: dict | None = None, 242 | ) -> None: 243 | retrieved = [] 244 | 245 | res = make_request(stapi_client, url, method, body, limit) 246 | assert res.status_code == status.HTTP_200_OK 247 | resp_body = res.json() 248 | 249 | assert len(resp_body[target]) <= limit 250 | retrieved.extend(resp_body[target]) 251 | next_url = next((d["href"] for d in resp_body["links"] if d["rel"] == "next"), None) 252 | 253 | while next_url: 254 | if method == "POST": 255 | body = next( 256 | (d["body"] for d in resp_body["links"] if d["rel"] == "next"), None 257 | ) 258 | 259 | res = make_request(stapi_client, next_url, method, body, limit) 260 | 261 | assert res.status_code == status.HTTP_200_OK, res.status_code 262 | assert len(resp_body[target]) <= limit 263 | 264 | resp_body = res.json() 265 | retrieved.extend(resp_body[target]) 266 | 267 | # get url w/ query params for next call if exists, and POST body if necessary 268 | if resp_body["links"]: 269 | next_url = next( 270 | (d["href"] for d in resp_body["links"] if d["rel"] == "next"), None 271 | ) 272 | else: 273 | next_url = None 274 | 275 | assert len(retrieved) == len(expected_returns) 276 | assert retrieved == expected_returns 277 | 278 | 279 | def make_request( 280 | stapi_client: TestClient, 281 | url: str, 282 | method: str, 283 | body: dict | None, 284 | limit: int, 285 | ) -> Response: 286 | """request wrapper for pagination tests""" 287 | 288 | match method: 289 | case "GET": 290 | o = urlparse(url) 291 | base_url = f"{o.scheme}://{o.netloc}{o.path}" 292 | parsed_qs = parse_qs(o.query) 293 | params: dict[str, Any] = {} 294 | if "next" in parsed_qs: 295 | params["next"] = parsed_qs["next"][0] 296 | params["limit"] = int(parsed_qs.get("limit", [None])[0] or limit) 297 | res = stapi_client.get(base_url, params=params) 298 | case "POST": 299 | res = stapi_client.post(url, json=body) 300 | case _: 301 | fail(f"method {method} not supported in make request") 302 | 303 | return res 304 | -------------------------------------------------------------------------------- /tests/test_opportunity_async.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime, timedelta, timezone 2 | from typing import Any, Callable 3 | from uuid import uuid4 4 | 5 | import pytest 6 | from fastapi import status 7 | from fastapi.testclient import TestClient 8 | 9 | from stapi_fastapi.models.opportunity import ( 10 | OpportunityCollection, 11 | OpportunitySearchRecord, 12 | OpportunitySearchStatus, 13 | OpportunitySearchStatusCode, 14 | ) 15 | from stapi_fastapi.models.shared import Link 16 | 17 | from .shared import ( 18 | create_mock_opportunity, 19 | find_link, 20 | pagination_tester, 21 | product_test_spotlight, 22 | product_test_spotlight_async_opportunity, 23 | product_test_spotlight_sync_async_opportunity, 24 | product_test_spotlight_sync_opportunity, 25 | ) 26 | from .test_datetime_interval import rfc3339_strftime 27 | 28 | 29 | @pytest.mark.mock_products([product_test_spotlight]) 30 | def test_no_opportunity_search_advertised(stapi_client: TestClient) -> None: 31 | product_id = "test-spotlight" 32 | 33 | # the `/products/{productId}/opportunities link should not be advertised on the product 34 | product_response = stapi_client.get(f"/products/{product_id}") 35 | product_body = product_response.json() 36 | assert find_link(product_body["links"], "opportunities") is None 37 | 38 | # the `searches/opportunities` link should not be advertised on the root 39 | root_response = stapi_client.get("/") 40 | root_body = root_response.json() 41 | assert find_link(root_body["links"], "opportunity-search-records") is None 42 | 43 | 44 | @pytest.mark.mock_products([product_test_spotlight_sync_opportunity]) 45 | def test_only_sync_search_advertised(stapi_client: TestClient) -> None: 46 | product_id = "test-spotlight" 47 | 48 | # the `/products/{productId}/opportunities link should be advertised on the product 49 | product_response = stapi_client.get(f"/products/{product_id}") 50 | product_body = product_response.json() 51 | assert find_link(product_body["links"], "opportunities") 52 | 53 | # the `searches/opportunities` link should not be advertised on the root 54 | root_response = stapi_client.get("/") 55 | root_body = root_response.json() 56 | assert find_link(root_body["links"], "opportunity-search-records") is None 57 | 58 | 59 | # test async search offered 60 | @pytest.mark.parametrize( 61 | "mock_products", 62 | [ 63 | [product_test_spotlight_async_opportunity], 64 | [product_test_spotlight_sync_async_opportunity], 65 | ], 66 | ) 67 | def test_async_search_advertised(stapi_client_async_opportunity: TestClient) -> None: 68 | product_id = "test-spotlight" 69 | 70 | # the `/products/{productId}/opportunities link should be advertised on the product 71 | product_response = stapi_client_async_opportunity.get(f"/products/{product_id}") 72 | product_body = product_response.json() 73 | assert find_link(product_body["links"], "opportunities") 74 | 75 | # the `searches/opportunities` link should be advertised on the root 76 | root_response = stapi_client_async_opportunity.get("/") 77 | root_body = root_response.json() 78 | assert find_link(root_body["links"], "opportunity-search-records") 79 | 80 | 81 | @pytest.mark.mock_products([product_test_spotlight_async_opportunity]) 82 | def test_async_search_response( 83 | stapi_client_async_opportunity: TestClient, 84 | opportunity_search: dict[str, Any], 85 | ) -> None: 86 | product_id = "test-spotlight" 87 | url = f"/products/{product_id}/opportunities" 88 | 89 | response = stapi_client_async_opportunity.post(url, json=opportunity_search) 90 | assert response.status_code == 201 91 | 92 | body = response.json() 93 | try: 94 | _ = OpportunitySearchRecord(**body) 95 | except Exception as _: 96 | pytest.fail("response is not an opportunity search record") 97 | 98 | assert find_link(body["links"], "self") 99 | 100 | 101 | @pytest.mark.mock_products([product_test_spotlight_async_opportunity]) 102 | def test_async_search_is_default( 103 | stapi_client_async_opportunity: TestClient, 104 | opportunity_search: dict[str, Any], 105 | ) -> None: 106 | product_id = "test-spotlight" 107 | url = f"/products/{product_id}/opportunities" 108 | 109 | response = stapi_client_async_opportunity.post(url, json=opportunity_search) 110 | assert response.status_code == 201 111 | 112 | body = response.json() 113 | try: 114 | _ = OpportunitySearchRecord(**body) 115 | except Exception as _: 116 | pytest.fail("response is not an opportunity search record") 117 | 118 | 119 | @pytest.mark.mock_products([product_test_spotlight_sync_async_opportunity]) 120 | def test_prefer_header( 121 | stapi_client_async_opportunity: TestClient, 122 | opportunity_search: dict[str, Any], 123 | ) -> None: 124 | product_id = "test-spotlight" 125 | url = f"/products/{product_id}/opportunities" 126 | 127 | # prefer = "wait" 128 | response = stapi_client_async_opportunity.post( 129 | url, json=opportunity_search, headers={"Prefer": "wait"} 130 | ) 131 | assert response.status_code == 200 132 | assert response.headers["Preference-Applied"] == "wait" 133 | 134 | body = response.json() 135 | try: 136 | OpportunityCollection(**body) 137 | except Exception as _: 138 | pytest.fail("response is not an opportunity collection") 139 | 140 | # prefer = "respond-async" 141 | response = stapi_client_async_opportunity.post( 142 | url, json=opportunity_search, headers={"Prefer": "respond-async"} 143 | ) 144 | assert response.status_code == 201 145 | assert response.headers["Preference-Applied"] == "respond-async" 146 | 147 | body = response.json() 148 | try: 149 | OpportunitySearchRecord(**body) 150 | except Exception as _: 151 | pytest.fail("response is not an opportunity search record") 152 | 153 | 154 | @pytest.mark.mock_products([product_test_spotlight_async_opportunity]) 155 | def test_async_search_record_retrieval( 156 | stapi_client_async_opportunity: TestClient, 157 | opportunity_search: dict[str, Any], 158 | ) -> None: 159 | # post an async search 160 | product_id = "test-spotlight" 161 | url = f"/products/{product_id}/opportunities" 162 | search_response = stapi_client_async_opportunity.post(url, json=opportunity_search) 163 | assert search_response.status_code == 201 164 | search_response_body = search_response.json() 165 | 166 | # get the search record by id and verify it matches the original response 167 | search_record_id = search_response_body["id"] 168 | record_response = stapi_client_async_opportunity.get( 169 | f"/searches/opportunities/{search_record_id}" 170 | ) 171 | assert record_response.status_code == 200 172 | record_response_body = record_response.json() 173 | assert record_response_body == search_response_body 174 | 175 | # verify the search record is in the list of all search records 176 | records_response = stapi_client_async_opportunity.get("/searches/opportunities") 177 | assert records_response.status_code == 200 178 | records_response_body = records_response.json() 179 | assert search_record_id in [ 180 | x["id"] for x in records_response_body["search_records"] 181 | ] 182 | 183 | 184 | @pytest.mark.mock_products([product_test_spotlight_async_opportunity]) 185 | def test_async_opportunity_search_to_completion( 186 | stapi_client_async_opportunity: TestClient, 187 | opportunity_search: dict[str, Any], 188 | url_for: Callable[[str], str], 189 | ) -> None: 190 | # Post a request for an async search 191 | product_id = "test-spotlight" 192 | url = f"/products/{product_id}/opportunities" 193 | search_response = stapi_client_async_opportunity.post(url, json=opportunity_search) 194 | assert search_response.status_code == 201 195 | search_record = OpportunitySearchRecord(**search_response.json()) 196 | 197 | # Simulate the search being completed by some external process: 198 | # - an OpportunityCollection is created and stored in the database 199 | collection = OpportunityCollection( 200 | id=str(uuid4()), 201 | features=[create_mock_opportunity()], 202 | ) 203 | collection.links.append( 204 | Link( 205 | rel="create-order", 206 | href=url_for(f"/products/{product_id}/orders"), 207 | body=search_record.opportunity_request.model_dump(), 208 | method="POST", 209 | ) 210 | ) 211 | collection.links.append( 212 | Link( 213 | rel="search-record", 214 | href=url_for(f"/searches/opportunities/{search_record.id}"), 215 | ) 216 | ) 217 | 218 | stapi_client_async_opportunity.app_state[ 219 | "_opportunities_db" 220 | ].put_opportunity_collection(collection) 221 | 222 | # - the OpportunitySearchRecord links and status are updated in the database 223 | search_record.links.append( 224 | Link( 225 | rel="opportunities", 226 | href=url_for(f"/products/{product_id}/opportunities/{collection.id}"), 227 | ) 228 | ) 229 | search_record.status = OpportunitySearchStatus( 230 | timestamp=datetime.now(timezone.utc), 231 | status_code=OpportunitySearchStatusCode.completed, 232 | ) 233 | 234 | stapi_client_async_opportunity.app_state["_opportunities_db"].put_search_record( 235 | search_record 236 | ) 237 | 238 | # Verify we can retrieve the OpportunitySearchRecord by its id and its status is 239 | # `completed` 240 | url = f"/searches/opportunities/{search_record.id}" 241 | retrieved_search_response = stapi_client_async_opportunity.get(url) 242 | assert retrieved_search_response.status_code == 200 243 | retrieved_search_record = OpportunitySearchRecord( 244 | **retrieved_search_response.json() 245 | ) 246 | assert ( 247 | retrieved_search_record.status.status_code 248 | == OpportunitySearchStatusCode.completed 249 | ) 250 | 251 | # Verify we can retrieve the OpportunityCollection from the 252 | # OpportunitySearchRecord's `opportunities` link; verify the retrieved 253 | # OpportunityCollection contains an order link and a link pointing back to the 254 | # OpportunitySearchRecord 255 | opportunities_link = next( 256 | x for x in retrieved_search_record.links if x.rel == "opportunities" 257 | ) 258 | url = str(opportunities_link.href) 259 | retrieved_collection_response = stapi_client_async_opportunity.get(url) 260 | assert retrieved_collection_response.status_code == 200 261 | retrieved_collection = OpportunityCollection(**retrieved_collection_response.json()) 262 | assert any(x for x in retrieved_collection.links if x.rel == "create-order") 263 | assert any(x for x in retrieved_collection.links if x.rel == "search-record") 264 | 265 | 266 | @pytest.mark.mock_products([product_test_spotlight_async_opportunity]) 267 | def test_new_search_location_header_matches_self_link( 268 | stapi_client_async_opportunity: TestClient, 269 | opportunity_search: dict[str, Any], 270 | ) -> None: 271 | product_id = "test-spotlight" 272 | url = f"/products/{product_id}/opportunities" 273 | search_response = stapi_client_async_opportunity.post(url, json=opportunity_search) 274 | assert search_response.status_code == 201 275 | 276 | search_record = search_response.json() 277 | link = find_link(search_record["links"], "self") 278 | assert link 279 | assert search_response.headers["Location"] == str(link["href"]) 280 | 281 | 282 | @pytest.mark.mock_products([product_test_spotlight_async_opportunity]) 283 | def test_bad_ids(stapi_client_async_opportunity: TestClient) -> None: 284 | search_record_id = "bad_id" 285 | res = stapi_client_async_opportunity.get( 286 | f"/searches/opportunities/{search_record_id}" 287 | ) 288 | assert res.status_code == status.HTTP_404_NOT_FOUND 289 | 290 | product_id = "test-spotlight" 291 | opportunity_collection_id = "bad_id" 292 | res = stapi_client_async_opportunity.get( 293 | f"/products/{product_id}/opportunities/{opportunity_collection_id}" 294 | ) 295 | assert res.status_code == status.HTTP_404_NOT_FOUND 296 | 297 | 298 | @pytest.fixture 299 | def setup_search_record_pagination( 300 | stapi_client_async_opportunity: TestClient, 301 | ) -> list[dict[str, Any]]: 302 | product_id = "test-spotlight" 303 | search_records = [] 304 | for _ in range(3): 305 | now = datetime.now(UTC) 306 | end = now + timedelta(days=5) 307 | format = "%Y-%m-%dT%H:%M:%S.%f%z" 308 | start_string = rfc3339_strftime(now, format) 309 | end_string = rfc3339_strftime(end, format) 310 | 311 | opportunity_request = { 312 | "geometry": { 313 | "type": "Point", 314 | "coordinates": [0, 0], 315 | }, 316 | "datetime": f"{start_string}/{end_string}", 317 | "filter": { 318 | "op": "and", 319 | "args": [ 320 | {"op": ">", "args": [{"property": "off_nadir"}, 0]}, 321 | {"op": "<", "args": [{"property": "off_nadir"}, 45]}, 322 | ], 323 | }, 324 | } 325 | 326 | response = stapi_client_async_opportunity.post( 327 | f"/products/{product_id}/opportunities", json=opportunity_request 328 | ) 329 | assert response.status_code == 201 330 | 331 | body = response.json() 332 | search_records.append(body) 333 | 334 | return search_records 335 | 336 | 337 | @pytest.mark.parametrize("limit", [0, 1, 2, 4]) 338 | @pytest.mark.mock_products([product_test_spotlight_async_opportunity]) 339 | def test_get_search_records_pagination( 340 | stapi_client_async_opportunity: TestClient, 341 | setup_search_record_pagination: list[dict[str, Any]], 342 | limit: int, 343 | ) -> None: 344 | expected_returns = [] 345 | if limit > 0: 346 | expected_returns = setup_search_record_pagination 347 | 348 | pagination_tester( 349 | stapi_client=stapi_client_async_opportunity, 350 | url="/searches/opportunities", 351 | method="GET", 352 | limit=limit, 353 | target="search_records", 354 | expected_returns=expected_returns, 355 | ) 356 | -------------------------------------------------------------------------------- /src/stapi_fastapi/routers/product_router.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import traceback 5 | from typing import TYPE_CHECKING 6 | 7 | from fastapi import ( 8 | APIRouter, 9 | Depends, 10 | Header, 11 | HTTPException, 12 | Request, 13 | Response, 14 | status, 15 | ) 16 | from fastapi.responses import JSONResponse 17 | from geojson_pydantic.geometries import Geometry 18 | from returns.maybe import Maybe, Some 19 | from returns.result import Failure, Success 20 | 21 | from stapi_fastapi.constants import TYPE_JSON 22 | from stapi_fastapi.exceptions import ConstraintsException, NotFoundException 23 | from stapi_fastapi.models.opportunity import ( 24 | OpportunityCollection, 25 | OpportunityPayload, 26 | OpportunitySearchRecord, 27 | Prefer, 28 | ) 29 | from stapi_fastapi.models.order import Order, OrderPayload 30 | from stapi_fastapi.models.product import Product 31 | from stapi_fastapi.models.shared import Link 32 | from stapi_fastapi.responses import GeoJSONResponse 33 | from stapi_fastapi.routers.route_names import ( 34 | CREATE_ORDER, 35 | GET_CONSTRAINTS, 36 | GET_OPPORTUNITY_COLLECTION, 37 | GET_ORDER_PARAMETERS, 38 | GET_PRODUCT, 39 | SEARCH_OPPORTUNITIES, 40 | ) 41 | from stapi_fastapi.types.json_schema_model import JsonSchemaModel 42 | 43 | if TYPE_CHECKING: 44 | from stapi_fastapi.routers import RootRouter 45 | 46 | logger = logging.getLogger(__name__) 47 | 48 | 49 | def get_prefer(prefer: str | None = Header(None)) -> str | None: 50 | if prefer is None: 51 | return None 52 | 53 | if prefer not in Prefer: 54 | raise HTTPException( 55 | status_code=status.HTTP_400_BAD_REQUEST, 56 | detail=f"Invalid Prefer header value: {prefer}", 57 | ) 58 | 59 | return Prefer(prefer) 60 | 61 | 62 | class ProductRouter(APIRouter): 63 | def __init__( 64 | self, 65 | product: Product, 66 | root_router: RootRouter, 67 | *args, 68 | **kwargs, 69 | ) -> None: 70 | super().__init__(*args, **kwargs) 71 | 72 | if ( 73 | root_router.supports_async_opportunity_search 74 | and not product.supports_async_opportunity_search 75 | ): 76 | raise ValueError( 77 | f"Product '{product.id}' must support async opportunity search since " 78 | f"the root router does", 79 | ) 80 | 81 | self.product = product 82 | self.root_router = root_router 83 | 84 | self.add_api_route( 85 | path="", 86 | endpoint=self.get_product, 87 | name=f"{self.root_router.name}:{self.product.id}:{GET_PRODUCT}", 88 | methods=["GET"], 89 | summary="Retrieve this product", 90 | tags=["Products"], 91 | ) 92 | 93 | self.add_api_route( 94 | path="/constraints", 95 | endpoint=self.get_product_constraints, 96 | name=f"{self.root_router.name}:{self.product.id}:{GET_CONSTRAINTS}", 97 | methods=["GET"], 98 | summary="Get constraints for the product", 99 | tags=["Products"], 100 | ) 101 | 102 | self.add_api_route( 103 | path="/order-parameters", 104 | endpoint=self.get_product_order_parameters, 105 | name=f"{self.root_router.name}:{self.product.id}:{GET_ORDER_PARAMETERS}", 106 | methods=["GET"], 107 | summary="Get order parameters for the product", 108 | tags=["Products"], 109 | ) 110 | 111 | # This wraps `self.create_order` to explicitly parameterize `OrderRequest` 112 | # for this Product. This must be done programmatically instead of with a type 113 | # annotation because it's setting the type dynamically instead of statically, and 114 | # pydantic needs this type annotation when doing object conversion. This cannot be done 115 | # directly to `self.create_order` because doing it there changes 116 | # the annotation on every `ProductRouter` instance's `create_order`, not just 117 | # this one's. 118 | async def _create_order( 119 | payload: OrderPayload, 120 | request: Request, 121 | response: Response, 122 | ) -> Order: 123 | return await self.create_order(payload, request, response) 124 | 125 | _create_order.__annotations__["payload"] = OrderPayload[ 126 | self.product.order_parameters # type: ignore 127 | ] 128 | 129 | self.add_api_route( 130 | path="/orders", 131 | endpoint=_create_order, 132 | name=f"{self.root_router.name}:{self.product.id}:{CREATE_ORDER}", 133 | methods=["POST"], 134 | response_class=GeoJSONResponse, 135 | status_code=status.HTTP_201_CREATED, 136 | summary="Create an order for the product", 137 | tags=["Products"], 138 | ) 139 | 140 | if ( 141 | product.supports_opportunity_search 142 | or root_router.supports_async_opportunity_search 143 | ): 144 | self.add_api_route( 145 | path="/opportunities", 146 | endpoint=self.search_opportunities, 147 | name=f"{self.root_router.name}:{self.product.id}:{SEARCH_OPPORTUNITIES}", 148 | methods=["POST"], 149 | response_class=GeoJSONResponse, 150 | # unknown why mypy can't see the constraints property on Product, ignoring 151 | response_model=OpportunityCollection[ 152 | Geometry, 153 | self.product.opportunity_properties, # type: ignore 154 | ], 155 | responses={ 156 | 201: { 157 | "model": OpportunitySearchRecord, 158 | "content": {TYPE_JSON: {}}, 159 | } 160 | }, 161 | summary="Search Opportunities for the product", 162 | tags=["Products"], 163 | ) 164 | 165 | if root_router.supports_async_opportunity_search: 166 | self.add_api_route( 167 | path="/opportunities/{opportunity_collection_id}", 168 | endpoint=self.get_opportunity_collection, 169 | name=f"{self.root_router.name}:{self.product.id}:{GET_OPPORTUNITY_COLLECTION}", 170 | methods=["GET"], 171 | response_class=GeoJSONResponse, 172 | summary="Get an Opportunity Collection by ID", 173 | tags=["Products"], 174 | ) 175 | 176 | def get_product(self, request: Request) -> Product: 177 | links = [ 178 | Link( 179 | href=str( 180 | request.url_for( 181 | f"{self.root_router.name}:{self.product.id}:{GET_PRODUCT}", 182 | ), 183 | ), 184 | rel="self", 185 | type=TYPE_JSON, 186 | ), 187 | Link( 188 | href=str( 189 | request.url_for( 190 | f"{self.root_router.name}:{self.product.id}:{GET_CONSTRAINTS}", 191 | ), 192 | ), 193 | rel="constraints", 194 | type=TYPE_JSON, 195 | ), 196 | Link( 197 | href=str( 198 | request.url_for( 199 | f"{self.root_router.name}:{self.product.id}:{GET_ORDER_PARAMETERS}", 200 | ), 201 | ), 202 | rel="order-parameters", 203 | type=TYPE_JSON, 204 | ), 205 | Link( 206 | href=str( 207 | request.url_for( 208 | f"{self.root_router.name}:{self.product.id}:{CREATE_ORDER}", 209 | ), 210 | ), 211 | rel="create-order", 212 | type=TYPE_JSON, 213 | method="POST", 214 | ), 215 | ] 216 | 217 | if ( 218 | self.product.supports_opportunity_search 219 | or self.root_router.supports_async_opportunity_search 220 | ): 221 | links.append( 222 | Link( 223 | href=str( 224 | request.url_for( 225 | f"{self.root_router.name}:{self.product.id}:{SEARCH_OPPORTUNITIES}", 226 | ), 227 | ), 228 | rel="opportunities", 229 | type=TYPE_JSON, 230 | ), 231 | ) 232 | 233 | return self.product.with_links(links=links) 234 | 235 | async def search_opportunities( 236 | self, 237 | search: OpportunityPayload, 238 | request: Request, 239 | response: Response, 240 | prefer: Prefer | None = Depends(get_prefer), 241 | ) -> OpportunityCollection | Response: 242 | """ 243 | Explore the opportunities available for a particular set of constraints 244 | """ 245 | # sync 246 | if not self.root_router.supports_async_opportunity_search or ( 247 | prefer is Prefer.wait and self.product.supports_opportunity_search 248 | ): 249 | return await self.search_opportunities_sync( 250 | search, 251 | request, 252 | response, 253 | prefer, 254 | ) 255 | 256 | # async 257 | if ( 258 | prefer is None 259 | or prefer is Prefer.respond_async 260 | or (prefer is Prefer.wait and not self.product.supports_opportunity_search) 261 | ): 262 | return await self.search_opportunities_async(search, request, prefer) 263 | 264 | raise AssertionError("Expected code to be unreachable") 265 | 266 | async def search_opportunities_sync( 267 | self, 268 | search: OpportunityPayload, 269 | request: Request, 270 | response: Response, 271 | prefer: Prefer | None, 272 | ) -> OpportunityCollection: 273 | links: list[Link] = [] 274 | match await self.product.search_opportunities( 275 | self, 276 | search, 277 | search.next, 278 | search.limit, 279 | request, 280 | ): 281 | case Success((features, maybe_pagination_token)): 282 | links.append(self.order_link(request, search)) 283 | match maybe_pagination_token: 284 | case Some(x): 285 | links.append(self.pagination_link(request, search, x)) 286 | case Maybe.empty: 287 | pass 288 | case Failure(e) if isinstance(e, ConstraintsException): 289 | raise e 290 | case Failure(e): 291 | logger.error( 292 | "An error occurred while searching opportunities: %s", 293 | traceback.format_exception(e), 294 | ) 295 | raise HTTPException( 296 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 297 | detail="Error searching opportunities", 298 | ) 299 | case x: 300 | raise AssertionError(f"Expected code to be unreachable {x}") 301 | 302 | if prefer is Prefer.wait and self.root_router.supports_async_opportunity_search: 303 | response.headers["Preference-Applied"] = "wait" 304 | 305 | return OpportunityCollection(features=features, links=links) 306 | 307 | async def search_opportunities_async( 308 | self, 309 | search: OpportunityPayload, 310 | request: Request, 311 | prefer: Prefer | None, 312 | ) -> JSONResponse: 313 | match await self.product.search_opportunities_async(self, search, request): 314 | case Success(search_record): 315 | search_record.links.append( 316 | self.root_router.opportunity_search_record_self_link( 317 | search_record, request 318 | ) 319 | ) 320 | headers = {} 321 | headers["Location"] = str( 322 | self.root_router.generate_opportunity_search_record_href( 323 | request, search_record.id 324 | ) 325 | ) 326 | if prefer is not None: 327 | headers["Preference-Applied"] = "respond-async" 328 | return JSONResponse( 329 | status_code=201, 330 | content=search_record.model_dump(mode="json"), 331 | headers=headers, 332 | ) 333 | case Failure(e) if isinstance(e, ConstraintsException): 334 | raise e 335 | case Failure(e): 336 | logger.error( 337 | "An error occurred while initiating an asynchronous opportunity search: %s", 338 | traceback.format_exception(e), 339 | ) 340 | raise HTTPException( 341 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 342 | detail="Error initiating an asynchronous opportunity search", 343 | ) 344 | case x: 345 | raise AssertionError(f"Expected code to be unreachable: {x}") 346 | 347 | def get_product_constraints(self) -> JsonSchemaModel: 348 | """ 349 | Return supported constraints of a specific product 350 | """ 351 | return self.product.constraints 352 | 353 | def get_product_order_parameters(self) -> JsonSchemaModel: 354 | """ 355 | Return supported constraints of a specific product 356 | """ 357 | return self.product.order_parameters 358 | 359 | async def create_order( 360 | self, payload: OrderPayload, request: Request, response: Response 361 | ) -> Order: 362 | """ 363 | Create a new order. 364 | """ 365 | match await self.product.create_order( 366 | self, 367 | payload, 368 | request, 369 | ): 370 | case Success(order): 371 | order.links.extend(self.root_router.order_links(order, request)) 372 | location = str(self.root_router.generate_order_href(request, order.id)) 373 | response.headers["Location"] = location 374 | return order 375 | case Failure(e) if isinstance(e, ConstraintsException): 376 | raise e 377 | case Failure(e): 378 | logger.error( 379 | "An error occurred while creating order: %s", 380 | traceback.format_exception(e), 381 | ) 382 | raise HTTPException( 383 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 384 | detail="Error creating order", 385 | ) 386 | case x: 387 | raise AssertionError(f"Expected code to be unreachable {x}") 388 | 389 | def order_link(self, request: Request, opp_req: OpportunityPayload): 390 | return Link( 391 | href=str( 392 | request.url_for( 393 | f"{self.root_router.name}:{self.product.id}:{CREATE_ORDER}", 394 | ), 395 | ), 396 | rel="create-order", 397 | type=TYPE_JSON, 398 | method="POST", 399 | body=opp_req.search_body(), 400 | ) 401 | 402 | def pagination_link( 403 | self, request: Request, opp_req: OpportunityPayload, pagination_token: str 404 | ): 405 | body = opp_req.body() 406 | body["next"] = pagination_token 407 | return Link( 408 | href=str(request.url), 409 | rel="next", 410 | type=TYPE_JSON, 411 | method="POST", 412 | body=body, 413 | ) 414 | 415 | async def get_opportunity_collection( 416 | self, opportunity_collection_id: str, request: Request 417 | ) -> OpportunityCollection: 418 | """ 419 | Fetch an opportunity collection generated by an asynchronous opportunity search. 420 | """ 421 | match await self.product.get_opportunity_collection( 422 | self, 423 | opportunity_collection_id, 424 | request, 425 | ): 426 | case Success(Some(opportunity_collection)): 427 | opportunity_collection.links.append( 428 | Link( 429 | href=str( 430 | request.url_for( 431 | f"{self.root_router.name}:{self.product.id}:{GET_OPPORTUNITY_COLLECTION}", 432 | opportunity_collection_id=opportunity_collection_id, 433 | ), 434 | ), 435 | rel="self", 436 | type=TYPE_JSON, 437 | ), 438 | ) 439 | return opportunity_collection 440 | case Success(Maybe.empty): 441 | raise NotFoundException("Opportunity Collection not found") 442 | case Failure(e): 443 | logger.error( 444 | "An error occurred while fetching opportunity collection: '%s': %s", 445 | opportunity_collection_id, 446 | traceback.format_exception(e), 447 | ) 448 | raise HTTPException( 449 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 450 | detail="Error fetching Opportunity Collection", 451 | ) 452 | case x: 453 | raise AssertionError(f"Expected code to be unreachable {x}") 454 | -------------------------------------------------------------------------------- /src/stapi_fastapi/routers/root_router.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import traceback 3 | 4 | from fastapi import APIRouter, HTTPException, Request, status 5 | from fastapi.datastructures import URL 6 | from returns.maybe import Maybe, Some 7 | from returns.result import Failure, Success 8 | 9 | from stapi_fastapi.backends.root_backend import ( 10 | GetOpportunitySearchRecord, 11 | GetOpportunitySearchRecords, 12 | GetOrder, 13 | GetOrders, 14 | GetOrderStatuses, 15 | ) 16 | from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON 17 | from stapi_fastapi.exceptions import NotFoundException 18 | from stapi_fastapi.models.conformance import ( 19 | ASYNC_OPPORTUNITIES, 20 | CORE, 21 | Conformance, 22 | ) 23 | from stapi_fastapi.models.opportunity import ( 24 | OpportunitySearchRecord, 25 | OpportunitySearchRecords, 26 | ) 27 | from stapi_fastapi.models.order import ( 28 | Order, 29 | OrderCollection, 30 | OrderStatuses, 31 | ) 32 | from stapi_fastapi.models.product import Product, ProductsCollection 33 | from stapi_fastapi.models.root import RootResponse 34 | from stapi_fastapi.models.shared import Link 35 | from stapi_fastapi.responses import GeoJSONResponse 36 | from stapi_fastapi.routers.product_router import ProductRouter 37 | from stapi_fastapi.routers.route_names import ( 38 | CONFORMANCE, 39 | GET_OPPORTUNITY_SEARCH_RECORD, 40 | GET_ORDER, 41 | LIST_OPPORTUNITY_SEARCH_RECORDS, 42 | LIST_ORDER_STATUSES, 43 | LIST_ORDERS, 44 | LIST_PRODUCTS, 45 | ROOT, 46 | ) 47 | 48 | logger = logging.getLogger(__name__) 49 | 50 | 51 | class RootRouter(APIRouter): 52 | def __init__( 53 | self, 54 | get_orders: GetOrders, 55 | get_order: GetOrder, 56 | get_order_statuses: GetOrderStatuses, 57 | get_opportunity_search_records: GetOpportunitySearchRecords | None = None, 58 | get_opportunity_search_record: GetOpportunitySearchRecord | None = None, 59 | conformances: list[str] = [CORE], 60 | name: str = "root", 61 | openapi_endpoint_name: str = "openapi", 62 | docs_endpoint_name: str = "swagger_ui_html", 63 | *args, 64 | **kwargs, 65 | ) -> None: 66 | super().__init__(*args, **kwargs) 67 | 68 | if ASYNC_OPPORTUNITIES in conformances and ( 69 | not get_opportunity_search_records or not get_opportunity_search_record 70 | ): 71 | raise ValueError( 72 | "`get_opportunity_search_records` and `get_opportunity_search_record` " 73 | "are required when advertising async opportunity search conformance" 74 | ) 75 | 76 | self._get_orders = get_orders 77 | self._get_order = get_order 78 | self._get_order_statuses = get_order_statuses 79 | self.__get_opportunity_search_records = get_opportunity_search_records 80 | self.__get_opportunity_search_record = get_opportunity_search_record 81 | self.conformances = conformances 82 | self.name = name 83 | self.openapi_endpoint_name = openapi_endpoint_name 84 | self.docs_endpoint_name = docs_endpoint_name 85 | self.product_ids: list[str] = [] 86 | 87 | # A dict is used to track the product routers so we can ensure 88 | # idempotentcy in case a product is added multiple times, and also to 89 | # manage clobbering if multiple products with the same product_id are 90 | # added. 91 | self.product_routers: dict[str, ProductRouter] = {} 92 | 93 | self.add_api_route( 94 | "/", 95 | self.get_root, 96 | methods=["GET"], 97 | name=f"{self.name}:{ROOT}", 98 | tags=["Root"], 99 | ) 100 | 101 | self.add_api_route( 102 | "/conformance", 103 | self.get_conformance, 104 | methods=["GET"], 105 | name=f"{self.name}:{CONFORMANCE}", 106 | tags=["Conformance"], 107 | ) 108 | 109 | self.add_api_route( 110 | "/products", 111 | self.get_products, 112 | methods=["GET"], 113 | name=f"{self.name}:{LIST_PRODUCTS}", 114 | tags=["Products"], 115 | ) 116 | 117 | self.add_api_route( 118 | "/orders", 119 | self.get_orders, 120 | methods=["GET"], 121 | name=f"{self.name}:{LIST_ORDERS}", 122 | response_class=GeoJSONResponse, 123 | tags=["Orders"], 124 | ) 125 | 126 | self.add_api_route( 127 | "/orders/{order_id}", 128 | self.get_order, 129 | methods=["GET"], 130 | name=f"{self.name}:{GET_ORDER}", 131 | response_class=GeoJSONResponse, 132 | tags=["Orders"], 133 | ) 134 | 135 | self.add_api_route( 136 | "/orders/{order_id}/statuses", 137 | self.get_order_statuses, 138 | methods=["GET"], 139 | name=f"{self.name}:{LIST_ORDER_STATUSES}", 140 | tags=["Orders"], 141 | ) 142 | 143 | if ASYNC_OPPORTUNITIES in conformances: 144 | self.add_api_route( 145 | "/searches/opportunities", 146 | self.get_opportunity_search_records, 147 | methods=["GET"], 148 | name=f"{self.name}:{LIST_OPPORTUNITY_SEARCH_RECORDS}", 149 | summary="List all Opportunity Search Records", 150 | tags=["Opportunities"], 151 | ) 152 | 153 | self.add_api_route( 154 | "/searches/opportunities/{search_record_id}", 155 | self.get_opportunity_search_record, 156 | methods=["GET"], 157 | name=f"{self.name}:{GET_OPPORTUNITY_SEARCH_RECORD}", 158 | summary="Get an Opportunity Search Record by ID", 159 | tags=["Opportunities"], 160 | ) 161 | 162 | def get_root(self, request: Request) -> RootResponse: 163 | links = [ 164 | Link( 165 | href=str(request.url_for(f"{self.name}:{ROOT}")), 166 | rel="self", 167 | type=TYPE_JSON, 168 | ), 169 | Link( 170 | href=str(request.url_for(self.openapi_endpoint_name)), 171 | rel="service-description", 172 | type=TYPE_JSON, 173 | ), 174 | Link( 175 | href=str(request.url_for(self.docs_endpoint_name)), 176 | rel="service-docs", 177 | type="text/html", 178 | ), 179 | Link( 180 | href=str(request.url_for(f"{self.name}:{CONFORMANCE}")), 181 | rel="conformance", 182 | type=TYPE_JSON, 183 | ), 184 | Link( 185 | href=str(request.url_for(f"{self.name}:{LIST_PRODUCTS}")), 186 | rel="products", 187 | type=TYPE_JSON, 188 | ), 189 | Link( 190 | href=str(request.url_for(f"{self.name}:{LIST_ORDERS}")), 191 | rel="orders", 192 | type=TYPE_GEOJSON, 193 | ), 194 | ] 195 | 196 | if self.supports_async_opportunity_search: 197 | links.append( 198 | Link( 199 | href=str( 200 | request.url_for( 201 | f"{self.name}:{LIST_OPPORTUNITY_SEARCH_RECORDS}" 202 | ) 203 | ), 204 | rel="opportunity-search-records", 205 | type=TYPE_JSON, 206 | ), 207 | ) 208 | 209 | return RootResponse( 210 | id="STAPI API", 211 | conformsTo=self.conformances, 212 | links=links, 213 | ) 214 | 215 | def get_conformance(self) -> Conformance: 216 | return Conformance(conforms_to=self.conformances) 217 | 218 | def get_products( 219 | self, request: Request, next: str | None = None, limit: int = 10 220 | ) -> ProductsCollection: 221 | start = 0 222 | limit = min(limit, 100) 223 | try: 224 | if next: 225 | start = self.product_ids.index(next) 226 | except ValueError: 227 | logger.exception("An error occurred while retrieving products") 228 | raise NotFoundException( 229 | detail="Error finding pagination token for products" 230 | ) from None 231 | end = start + limit 232 | ids = self.product_ids[start:end] 233 | links = [ 234 | Link( 235 | href=str(request.url_for(f"{self.name}:{LIST_PRODUCTS}")), 236 | rel="self", 237 | type=TYPE_JSON, 238 | ), 239 | ] 240 | if end > 0 and end < len(self.product_ids): 241 | links.append(self.pagination_link(request, self.product_ids[end], limit)) 242 | return ProductsCollection( 243 | products=[ 244 | self.product_routers[product_id].get_product(request) 245 | for product_id in ids 246 | ], 247 | links=links, 248 | ) 249 | 250 | async def get_orders( 251 | self, request: Request, next: str | None = None, limit: int = 10 252 | ) -> OrderCollection: 253 | links: list[Link] = [] 254 | match await self._get_orders(next, limit, request): 255 | case Success((orders, maybe_pagination_token)): 256 | for order in orders: 257 | order.links.extend(self.order_links(order, request)) 258 | match maybe_pagination_token: 259 | case Some(x): 260 | links.append(self.pagination_link(request, x, limit)) 261 | case Maybe.empty: 262 | pass 263 | case Failure(ValueError()): 264 | raise NotFoundException(detail="Error finding pagination token") 265 | case Failure(e): 266 | logger.error( 267 | "An error occurred while retrieving orders: %s", 268 | traceback.format_exception(e), 269 | ) 270 | raise HTTPException( 271 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 272 | detail="Error finding Orders", 273 | ) 274 | case _: 275 | raise AssertionError("Expected code to be unreachable") 276 | return OrderCollection(features=orders, links=links) 277 | 278 | async def get_order(self, order_id: str, request: Request) -> Order: 279 | """ 280 | Get details for order with `order_id`. 281 | """ 282 | match await self._get_order(order_id, request): 283 | case Success(Some(order)): 284 | order.links.extend(self.order_links(order, request)) 285 | return order 286 | case Success(Maybe.empty): 287 | raise NotFoundException("Order not found") 288 | case Failure(e): 289 | logger.error( 290 | "An error occurred while retrieving order '%s': %s", 291 | order_id, 292 | traceback.format_exception(e), 293 | ) 294 | raise HTTPException( 295 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 296 | detail="Error finding Order", 297 | ) 298 | case _: 299 | raise AssertionError("Expected code to be unreachable") 300 | 301 | async def get_order_statuses( 302 | self, 303 | order_id: str, 304 | request: Request, 305 | next: str | None = None, 306 | limit: int = 10, 307 | ) -> OrderStatuses: 308 | links: list[Link] = [] 309 | match await self._get_order_statuses(order_id, next, limit, request): 310 | case Success(Some((statuses, maybe_pagination_token))): 311 | links.append(self.order_statuses_link(request, order_id)) 312 | match maybe_pagination_token: 313 | case Some(x): 314 | links.append(self.pagination_link(request, x, limit)) 315 | case Maybe.empty: 316 | pass 317 | case Success(Maybe.empty): 318 | raise NotFoundException("Order not found") 319 | case Failure(ValueError()): 320 | raise NotFoundException("Error finding pagination token") 321 | case Failure(e): 322 | logger.error( 323 | "An error occurred while retrieving order statuses: %s", 324 | traceback.format_exception(e), 325 | ) 326 | raise HTTPException( 327 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 328 | detail="Error finding Order Statuses", 329 | ) 330 | case _: 331 | raise AssertionError("Expected code to be unreachable") 332 | return OrderStatuses(statuses=statuses, links=links) 333 | 334 | def add_product(self, product: Product, *args, **kwargs) -> None: 335 | # Give the include a prefix from the product router 336 | product_router = ProductRouter(product, self, *args, **kwargs) 337 | self.include_router(product_router, prefix=f"/products/{product.id}") 338 | self.product_routers[product.id] = product_router 339 | self.product_ids = [*self.product_routers.keys()] 340 | 341 | def generate_order_href(self, request: Request, order_id: str) -> URL: 342 | return request.url_for(f"{self.name}:{GET_ORDER}", order_id=order_id) 343 | 344 | def generate_order_statuses_href(self, request: Request, order_id: str) -> URL: 345 | return request.url_for(f"{self.name}:{LIST_ORDER_STATUSES}", order_id=order_id) 346 | 347 | def order_links(self, order: Order, request: Request) -> list[Link]: 348 | return [ 349 | Link( 350 | href=str(self.generate_order_href(request, order.id)), 351 | rel="self", 352 | type=TYPE_GEOJSON, 353 | ), 354 | Link( 355 | href=str(self.generate_order_statuses_href(request, order.id)), 356 | rel="monitor", 357 | type=TYPE_JSON, 358 | ), 359 | ] 360 | 361 | def order_statuses_link(self, request: Request, order_id: str): 362 | return Link( 363 | href=str( 364 | request.url_for( 365 | f"{self.name}:{LIST_ORDER_STATUSES}", 366 | order_id=order_id, 367 | ) 368 | ), 369 | rel="self", 370 | type=TYPE_JSON, 371 | ) 372 | 373 | def pagination_link(self, request: Request, pagination_token: str, limit: int): 374 | return Link( 375 | href=str( 376 | request.url.include_query_params(next=pagination_token, limit=limit) 377 | ), 378 | rel="next", 379 | type=TYPE_JSON, 380 | ) 381 | 382 | async def get_opportunity_search_records( 383 | self, request: Request, next: str | None = None, limit: int = 10 384 | ) -> OpportunitySearchRecords: 385 | links: list[Link] = [] 386 | match await self._get_opportunity_search_records(next, limit, request): 387 | case Success((records, maybe_pagination_token)): 388 | for record in records: 389 | record.links.append( 390 | self.opportunity_search_record_self_link(record, request) 391 | ) 392 | match maybe_pagination_token: 393 | case Some(x): 394 | links.append(self.pagination_link(request, x, limit)) 395 | case Maybe.empty: 396 | pass 397 | case Failure(ValueError()): 398 | raise NotFoundException(detail="Error finding pagination token") 399 | case Failure(e): 400 | logger.error( 401 | "An error occurred while retrieving opportunity search records: %s", 402 | traceback.format_exception(e), 403 | ) 404 | raise HTTPException( 405 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 406 | detail="Error finding Opportunity Search Records", 407 | ) 408 | case _: 409 | raise AssertionError("Expected code to be unreachable") 410 | return OpportunitySearchRecords(search_records=records, links=links) 411 | 412 | async def get_opportunity_search_record( 413 | self, search_record_id: str, request: Request 414 | ) -> OpportunitySearchRecord: 415 | """ 416 | Get the Opportunity Search Record with `search_record_id`. 417 | """ 418 | match await self._get_opportunity_search_record(search_record_id, request): 419 | case Success(Some(search_record)): 420 | search_record.links.append( 421 | self.opportunity_search_record_self_link(search_record, request) 422 | ) 423 | return search_record 424 | case Success(Maybe.empty): 425 | raise NotFoundException("Opportunity Search Record not found") 426 | case Failure(e): 427 | logger.error( 428 | "An error occurred while retrieving opportunity search record '%s': %s", 429 | search_record_id, 430 | traceback.format_exception(e), 431 | ) 432 | raise HTTPException( 433 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 434 | detail="Error finding Opportunity Search Record", 435 | ) 436 | case _: 437 | raise AssertionError("Expected code to be unreachable") 438 | 439 | def generate_opportunity_search_record_href( 440 | self, request: Request, search_record_id: str 441 | ) -> URL: 442 | return request.url_for( 443 | f"{self.name}:{GET_OPPORTUNITY_SEARCH_RECORD}", 444 | search_record_id=search_record_id, 445 | ) 446 | 447 | def opportunity_search_record_self_link( 448 | self, opportunity_search_record: OpportunitySearchRecord, request: Request 449 | ) -> Link: 450 | return Link( 451 | href=str( 452 | self.generate_opportunity_search_record_href( 453 | request, opportunity_search_record.id 454 | ) 455 | ), 456 | rel="self", 457 | type=TYPE_JSON, 458 | ) 459 | 460 | @property 461 | def _get_opportunity_search_records(self) -> GetOpportunitySearchRecords: 462 | if not self.__get_opportunity_search_records: 463 | raise AttributeError( 464 | "Root router does not support async opportunity search" 465 | ) 466 | return self.__get_opportunity_search_records 467 | 468 | @property 469 | def _get_opportunity_search_record(self) -> GetOpportunitySearchRecord: 470 | if not self.__get_opportunity_search_record: 471 | raise AttributeError( 472 | "Root router does not support async opportunity search" 473 | ) 474 | return self.__get_opportunity_search_record 475 | 476 | @property 477 | def supports_async_opportunity_search(self) -> bool: 478 | return ( 479 | ASYNC_OPPORTUNITIES in self.conformances 480 | and self._get_opportunity_search_records is not None 481 | and self._get_opportunity_search_record is not None 482 | ) 483 | --------------------------------------------------------------------------------