diff --git a/README.md b/README.md index cca7148..d62ab17 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # STAPI FastAPI - Sensor Tasking API with FastAPI -WARNING: The whole [STAPI spec](https://github.com/stapi-spec/stapi-spec) is -very much work in progress, so things are guaranteed to be not correct. +WARNING: The whole [STAPI spec] is very much work in progress, so things are +guaranteed to be not correct. ## Usage @@ -11,7 +11,7 @@ STAPI FastAPI provides an `fastapi.APIRouter` which must be included in ## Development It's 2024 and we still need to pick our poison for a 2024 dependency management -solution. This project picks [poetry][poetry] for now. +solution. This project picks [poetry] for now. ### Dev Setup @@ -35,15 +35,22 @@ command `pytest`. ### Dev Server This project cannot be run on its own because it does not have any backend -implementations. If a development server is desired, run one of the -implementations such as -[stapi-fastapi-tle](https://github.com/stapi-spec/stapi-fastapi-tle). To test -something like stapi-fastapi-tle with your development version of -stapi-fastapi, install them both into the same virtual environment. +implementations. However, a minimal test implementation is provided in +[`./bin/server.py`](./bin/server.py). It can be run with `uvicorn` as a way to +interact with the API and to view the OpenAPI documentation. Run it like so +from the project root: + +```commandline +uvicorn server:app --app-dir ./bin --reload +``` + +With the `uvicorn` defaults the app should be accessible at +`http://localhost:8000`. ### Implementing a backend - The test suite assumes the backend can be instantiated without any parameters required by the constructor. +[STAPI spec]: https://github.com/stapi-spec/stapi-spec [poetry]: https://python-poetry.org/ diff --git a/bin/server.py b/bin/server.py new file mode 100644 index 0000000..b9a2971 --- /dev/null +++ b/bin/server.py @@ -0,0 +1,108 @@ +from uuid import uuid4 + +from fastapi import FastAPI, Request +from stapi_fastapi.backends.product_backend import ProductBackend +from stapi_fastapi.exceptions import ConstraintsException, NotFoundException +from stapi_fastapi.models.opportunity import ( + Opportunity, + OpportunityPropertiesBase, + OpportunityRequest, +) +from stapi_fastapi.models.order import Order +from stapi_fastapi.models.product import ( + Product, + Provider, + ProviderRole, +) +from stapi_fastapi.routers.root_router import RootRouter + + +class MockOrderDB(dict[int | str, Order]): + pass + + +class MockRootBackend: + def __init__(self, orders: MockOrderDB) -> None: + self._orders: MockOrderDB = orders + + async def get_orders(self, request: Request) -> list[Order]: + """ + Show all orders. + """ + return list(self._orders.values()) + + async def get_order(self, order_id: str, request: Request) -> Order: + """ + Show details for order with `order_id`. + """ + try: + return self._orders[order_id] + except KeyError: + raise NotFoundException() + + +class MockProductBackend(ProductBackend): + def __init__(self, orders: MockOrderDB) -> None: + self._opportunities: list[Opportunity] = [] + self._allowed_payloads: list[OpportunityRequest] = [] + self._orders: MockOrderDB = orders + + async def search_opportunities( + self, product: Product, search: OpportunityRequest, request: Request + ) -> list[Opportunity]: + return [o.model_copy(update=search.model_dump()) for o in self._opportunities] + + async def create_order( + self, product: Product, payload: OpportunityRequest, request: Request + ) -> Order: + """ + Create a new order. + """ + allowed: bool = any(allowed == payload for allowed in self._allowed_payloads) + if allowed: + order = Order( + id=str(uuid4()), + geometry=payload.geometry, + properties={ + "filter": payload.filter, + "datetime": payload.datetime, + "product_id": product.id, + }, + links=[], + ) + self._orders[order.id] = order + return order + raise ConstraintsException("not allowed") + + +class TestSpotlightProperties(OpportunityPropertiesBase): + off_nadir: int + + +order_db = MockOrderDB() +product_backend = MockProductBackend(order_db) +root_backend = MockRootBackend(order_db) + +provider = Provider( + name="Test Provider", + description="A provider for Test data", + roles=[ProviderRole.producer], # Example role + url="https://test-provider.example.com", # Must be a valid URL +) + +product = Product( + id="test-spotlight", + title="Test Spotlight Product", + description="Test product for test spotlight", + license="CC-BY-4.0", + keywords=["test", "satellite"], + providers=[provider], + links=[], + constraints=TestSpotlightProperties, + backend=product_backend, +) + +root_router = RootRouter(root_backend) +root_router.add_product(product) +app: FastAPI = FastAPI() +app.include_router(root_router, prefix="") diff --git a/poetry.lock b/poetry.lock index 00c4eb5..8e25812 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "annotated-types" @@ -207,29 +207,29 @@ langdetect = ["langdetect"] [[package]] name = "distlib" -version = "0.3.8" +version = "0.3.9" description = "Distribution utilities" optional = false python-versions = "*" files = [ - {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, - {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, + {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, + {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, ] [[package]] name = "fastapi" -version = "0.115.0" +version = "0.115.4" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" files = [ - {file = "fastapi-0.115.0-py3-none-any.whl", hash = "sha256:17ea427674467486e997206a5ab25760f6b09e069f099b96f5b55a32fb6f1631"}, - {file = "fastapi-0.115.0.tar.gz", hash = "sha256:f93b4ca3529a8ebc6fc3fcf710e5efa8de3df9b41570958abf1d97d843138004"}, + {file = "fastapi-0.115.4-py3-none-any.whl", hash = "sha256:0b504a063ffb3cf96a5e27dc1bc32c80ca743a2528574f9cdc77daa2d31b4742"}, + {file = "fastapi-0.115.4.tar.gz", hash = "sha256:db653475586b091cb8b2fec2ac54a680ac6a158e07406e1abae31679e8826349"}, ] [package.dependencies] pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" -starlette = ">=0.37.2,<0.39.0" +starlette = ">=0.40.0,<0.42.0" typing-extensions = ">=4.8.0" [package.extras] @@ -995,13 +995,13 @@ files = [ [[package]] name = "starlette" -version = "0.38.6" +version = "0.41.2" description = "The little ASGI library that shines." optional = false python-versions = ">=3.8" files = [ - {file = "starlette-0.38.6-py3-none-any.whl", hash = "sha256:4517a1409e2e73ee4951214ba012052b9e16f60e90d73cfb06192c19203bbb05"}, - {file = "starlette-0.38.6.tar.gz", hash = "sha256:863a1588f5574e70a821dadefb41e4881ea451a47a3cd1b4df359d4ffefe5ead"}, + {file = "starlette-0.41.2-py3-none-any.whl", hash = "sha256:fbc189474b4731cf30fcef52f18a8d070e3f3b46c6a04c97579e85e6ffca942d"}, + {file = "starlette-0.41.2.tar.gz", hash = "sha256:9834fd799d1a87fd346deb76158668cfa0b0d56f85caefe8268e2d97c3468b62"}, ] [package.dependencies] diff --git a/src/stapi_fastapi/__init__.py b/src/stapi_fastapi/__init__.py index e69de29..0e2c927 100644 --- a/src/stapi_fastapi/__init__.py +++ b/src/stapi_fastapi/__init__.py @@ -0,0 +1,21 @@ +from .backends import ProductBackend, RootBackend +from .models import ( + Link, + OpportunityPropertiesBase, + Product, + Provider, + ProviderRole, +) +from .routers import ProductRouter, RootRouter + +__all__ = [ + "Link", + "OpportunityPropertiesBase", + "Product", + "ProductBackend", + "ProductRouter", + "Provider", + "ProviderRole", + "RootBackend", + "RootRouter", +] diff --git a/src/stapi_fastapi/api.py b/src/stapi_fastapi/api.py deleted file mode 100644 index c7e19d6..0000000 --- a/src/stapi_fastapi/api.py +++ /dev/null @@ -1,215 +0,0 @@ -from fastapi import APIRouter, HTTPException, Request, status -from fastapi.encoders import jsonable_encoder -from fastapi.responses import JSONResponse - -from stapi_fastapi.backend import StapiBackend -from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON -from stapi_fastapi.exceptions import ConstraintsException, NotFoundException -from stapi_fastapi.models.opportunity import ( - OpportunityCollection, - OpportunityRequest, -) -from stapi_fastapi.models.order import Order -from stapi_fastapi.models.product import Product, ProductsCollection -from stapi_fastapi.models.root import RootResponse -from stapi_fastapi.models.shared import HTTPException as HTTPExceptionModel -from stapi_fastapi.models.shared import Link - - -class StapiException(HTTPException): - def __init__(self, status_code: int, detail: str) -> None: - super().__init__(status_code, detail) - - -class StapiRouter: - NAME_PREFIX = "stapi" - backend: StapiBackend - openapi_endpoint_name: str - docs_endpoint_name: str - router: APIRouter - - def __init__( - self, - backend: StapiBackend, - openapi_endpoint_name="openapi", - docs_endpoint_name="swagger_ui_html", - *args, - **kwargs, - ): - self.backend = backend - self.openapi_endpoint_name = openapi_endpoint_name - self.docs_endpoint_name = docs_endpoint_name - - self.router = APIRouter(*args, **kwargs) - self.router.add_api_route( - "/", - self.root, - methods=["GET"], - name=f"{self.NAME_PREFIX}:root", - tags=["Root"], - ) - - self.router.add_api_route( - "/products", - self.products, - methods=["GET"], - name=f"{self.NAME_PREFIX}:list-products", - tags=["Product"], - ) - self.router.add_api_route( - "/products/{product_id}", - self.product, - methods=["GET"], - name=f"{self.NAME_PREFIX}:get-product", - tags=["Product"], - responses={status.HTTP_404_NOT_FOUND: {"model": HTTPExceptionModel}}, - ) - - self.router.add_api_route( - "/opportunities", - self.search_opportunities, - methods=["POST"], - name=f"{self.NAME_PREFIX}:search-opportunities", - tags=["Opportunities"], - ) - - self.router.add_api_route( - "/orders", - self.create_order, - methods=["POST"], - name=f"{self.NAME_PREFIX}:create-order", - tags=["Orders"], - response_model=Order, - ) - self.router.add_api_route( - "/orders/{order_id}", - self.get_order, - methods=["GET"], - name=f"{self.NAME_PREFIX}:get-order", - tags=["Orders"], - ) - - def root(self, request: Request) -> RootResponse: - return RootResponse( - links=[ - Link( - href=str(request.url_for(f"{self.NAME_PREFIX}:root")), - rel="self", - type=TYPE_JSON, - ), - Link( - href=str(request.url_for(f"{self.NAME_PREFIX}:list-products")), - rel="products", - type=TYPE_JSON, - ), - Link( - href=str(request.url_for(self.openapi_endpoint_name)), - rel="service-description", - type=TYPE_JSON, - ), - Link( - href=str(request.url_for(self.docs_endpoint_name)), - rel="service-docs", - type="text/html", - ), - ] - ) - - def products(self, request: Request) -> ProductsCollection: - products = self.backend.products(request) - for product in products: - product.links.append( - Link( - href=str( - request.url_for( - f"{self.NAME_PREFIX}:get-product", product_id=product.id - ) - ), - rel="self", - type=TYPE_JSON, - ) - ) - return ProductsCollection( - products=products, - links=[ - Link( - href=str(request.url_for(f"{self.NAME_PREFIX}:list-products")), - rel="self", - type=TYPE_JSON, - ) - ], - ) - - def product(self, product_id: str, request: Request) -> Product: - try: - product = self.backend.product(product_id, request) - except NotFoundException as exc: - raise StapiException( - status.HTTP_404_NOT_FOUND, "product not found" - ) from exc - product.links.append( - Link( - href=str( - request.url_for( - f"{self.NAME_PREFIX}:get-product", product_id=product.id - ) - ), - rel="self", - type=TYPE_JSON, - ) - ) - return product - - async def search_opportunities( - self, search: OpportunityRequest, request: Request - ) -> OpportunityCollection: - """ - Explore the opportunities available for a particular set of constraints - """ - try: - opportunities = await self.backend.search_opportunities(search, request) - except ConstraintsException as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail) - return JSONResponse( - jsonable_encoder(OpportunityCollection(features=opportunities)), - media_type=TYPE_GEOJSON, - ) - - async def create_order( - self, search: OpportunityRequest, request: Request - ) -> JSONResponse: - """ - Create a new order. - """ - try: - order = await self.backend.create_order(search, request) - except ConstraintsException as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail) - - location = str( - request.url_for(f"{self.NAME_PREFIX}:get-order", order_id=order.id) - ) - order.links.append(Link(href=location, rel="self", type=TYPE_GEOJSON)) - return JSONResponse( - jsonable_encoder(order, exclude_unset=True), - status.HTTP_201_CREATED, - {"Location": location}, - TYPE_GEOJSON, - ) - - async def get_order(self, order_id: str, request: Request) -> Order: - """ - Get details for order with `order_id`. - """ - try: - order = await self.backend.get_order(order_id, request) - except NotFoundException as exc: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="not found") from exc - - order.links.append(Link(href=str(request.url), rel="self", type=TYPE_GEOJSON)) - - return JSONResponse( - jsonable_encoder(order, exclude_unset=True), - status.HTTP_200_OK, - media_type=TYPE_GEOJSON, - ) diff --git a/src/stapi_fastapi/backend.py b/src/stapi_fastapi/backend.py deleted file mode 100644 index 4023718..0000000 --- a/src/stapi_fastapi/backend.py +++ /dev/null @@ -1,46 +0,0 @@ -from typing import Protocol - -from fastapi import Request - -from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest -from stapi_fastapi.models.order import Order -from stapi_fastapi.models.product import Product - - -class StapiBackend(Protocol): - def products(self, request: Request) -> list[Product]: - """ - Return a list of supported products. - """ - - def product(self, product_id: str, request: Request) -> Product | None: - """ - Return the product identified by `product_id` or `None` if it isn't - supported. - """ - - async def search_opportunities( - self, search: OpportunityRequest, request: Request - ) -> list[Opportunity]: - """ - Search for ordering opportunities for the given search parameters. - - Backends must validate search constraints and raise - `stapi_fastapi.backend.exceptions.ConstraintsException` if not valid. - """ - - async def create_order(self, search: OpportunityRequest, request: Request) -> Order: - """ - Create a new order. - - Backends must validate order payload and raise - `stapi_fastapi.backend.exceptions.ConstraintsException` if not valid. - """ - - async def get_order(self, order_id: str, request: Request) -> Order: - """ - Get details for order with `order_id`. - - Backends must raise `stapi_fastapi.backend.exceptions.NotFoundException` - if not found or access denied. - """ diff --git a/src/stapi_fastapi/backends/__init__.py b/src/stapi_fastapi/backends/__init__.py new file mode 100644 index 0000000..b4b7ff2 --- /dev/null +++ b/src/stapi_fastapi/backends/__init__.py @@ -0,0 +1,7 @@ +from .product_backend import ProductBackend +from .root_backend import RootBackend + +__all__ = [ + "ProductBackend", + "RootBackend", +] diff --git a/src/stapi_fastapi/backends/product_backend.py b/src/stapi_fastapi/backends/product_backend.py new file mode 100644 index 0000000..e24ce08 --- /dev/null +++ b/src/stapi_fastapi/backends/product_backend.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import Protocol + +from fastapi import Request + +from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest +from stapi_fastapi.models.order import Order +from stapi_fastapi.routers.product_router import ProductRouter + + +class ProductBackend(Protocol): # pragma: nocover + async def search_opportunities( + self, + product_router: ProductRouter, + search: OpportunityRequest, + request: Request, + ) -> list[Opportunity]: + """ + Search for ordering opportunities for the given search parameters. + + Backends must validate search constraints and raise + `stapi_fastapi.exceptions.ConstraintsException` if not valid. + """ + ... + + async def create_order( + self, + product_router: ProductRouter, + search: OpportunityRequest, + request: Request, + ) -> Order: + """ + Create a new order. + + Backends must validate order payload and raise + `stapi_fastapi.exceptions.ConstraintsException` if not valid. + """ + ... diff --git a/src/stapi_fastapi/backends/root_backend.py b/src/stapi_fastapi/backends/root_backend.py new file mode 100644 index 0000000..b2831d9 --- /dev/null +++ b/src/stapi_fastapi/backends/root_backend.py @@ -0,0 +1,22 @@ +from typing import Protocol + +from fastapi import Request + +from stapi_fastapi.models.order import Order, OrderCollection + + +class RootBackend(Protocol): # pragma: nocover + async def get_orders(self, request: Request) -> OrderCollection: + """ + Return a list of existing orders. + """ + ... + + async def get_order(self, order_id: str, request: Request) -> Order: + """ + Get details for order with `order_id`. + + Backends must raise `stapi_fastapi.exceptions.NotFoundException` + if not found or access denied. + """ + ... diff --git a/src/stapi_fastapi/exceptions.py b/src/stapi_fastapi/exceptions.py index cbbf7e9..510d248 100644 --- a/src/stapi_fastapi/exceptions.py +++ b/src/stapi_fastapi/exceptions.py @@ -1,13 +1,17 @@ -from typing import Any, Mapping +from typing import Any +from fastapi import HTTPException, status -class ConstraintsException(Exception): - detail: Mapping[str, Any] | None - def __init__(self, detail: Mapping[str, Any] | None = None) -> None: - super().__init__() - self.detail = detail +class StapiException(HTTPException): + pass -class NotFoundException(Exception): - pass +class ConstraintsException(StapiException): + def __init__(self, detail: Any) -> None: + super().__init__(status.HTTP_422_UNPROCESSABLE_ENTITY, detail) + + +class NotFoundException(StapiException): + def __init__(self, detail: Any) -> None: + super().__init__(status.HTTP_404_NOT_FOUND, detail) diff --git a/src/stapi_fastapi/models/__init__.py b/src/stapi_fastapi/models/__init__.py index e69de29..fa45b2b 100644 --- a/src/stapi_fastapi/models/__init__.py +++ b/src/stapi_fastapi/models/__init__.py @@ -0,0 +1,11 @@ +from .opportunity import OpportunityPropertiesBase +from .product import Product, Provider, ProviderRole +from .shared import Link + +__all__ = [ + "Link", + "OpportunityPropertiesBase", + "Product", + "Provider", + "ProviderRole", +] diff --git a/src/stapi_fastapi/models/opportunity.py b/src/stapi_fastapi/models/opportunity.py index bc10333..ed10fed 100644 --- a/src/stapi_fastapi/models/opportunity.py +++ b/src/stapi_fastapi/models/opportunity.py @@ -1,8 +1,8 @@ -from typing import Literal, Optional +from typing import Literal, TypeVar from geojson_pydantic import Feature, FeatureCollection from geojson_pydantic.geometries import Geometry -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from stapi_fastapi.models.shared import Link from stapi_fastapi.types.datetime_interval import DatetimeInterval @@ -10,21 +10,28 @@ # Copied and modified from https://github.com/stac-utils/stac-pydantic/blob/main/stac_pydantic/item.py#L11 -class OpportunityProperties(BaseModel): +class OpportunityPropertiesBase(BaseModel): datetime: DatetimeInterval - product_id: str model_config = ConfigDict(extra="allow") -class OpportunityRequest(OpportunityProperties): +class OpportunityRequest(BaseModel): + datetime: DatetimeInterval geometry: Geometry - filter: Optional[CQL2Filter] = None + # TODO: validate the CQL2 filter? + filter: CQL2Filter | None = None + model_config = ConfigDict(strict=True) + + +G = TypeVar("G", bound=Geometry) +P = TypeVar("P", bound=OpportunityPropertiesBase) -class Opportunity(Feature[Geometry, OpportunityProperties]): +class Opportunity(Feature[G, P]): type: Literal["Feature"] = "Feature" - links: list[Link] = [] + links: list[Link] = Field(default_factory=list) -class OpportunityCollection(FeatureCollection[Opportunity]): +class OpportunityCollection(FeatureCollection[Opportunity[G, P]]): type: Literal["FeatureCollection"] = "FeatureCollection" + links: list[Link] = Field(default_factory=list) diff --git a/src/stapi_fastapi/models/order.py b/src/stapi_fastapi/models/order.py index 59419b9..f511a55 100644 --- a/src/stapi_fastapi/models/order.py +++ b/src/stapi_fastapi/models/order.py @@ -1,12 +1,26 @@ from typing import Literal -from geojson_pydantic import Feature +from geojson_pydantic import Feature, FeatureCollection from geojson_pydantic.geometries import Geometry +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr -from stapi_fastapi.models.opportunity import OpportunityProperties from stapi_fastapi.models.shared import Link +from stapi_fastapi.types.datetime_interval import DatetimeInterval -class Order(Feature[Geometry, OpportunityProperties]): +class OrderProperties(BaseModel): + datetime: DatetimeInterval + model_config = ConfigDict(extra="allow") + + +class Order(Feature[Geometry, OrderProperties]): + # We need to enforce that orders have an id defined, as that is required to + # retrieve them via the API + id: StrictInt | StrictStr # type: ignore type: Literal["Feature"] = "Feature" - links: list[Link] + links: list[Link] = Field(default_factory=list) + + +class OrderCollection(FeatureCollection[Order]): + type: Literal["FeatureCollection"] = "FeatureCollection" + links: list[Link] = Field(default_factory=list) diff --git a/src/stapi_fastapi/models/product.py b/src/stapi_fastapi/models/product.py index b8df057..27820fe 100644 --- a/src/stapi_fastapi/models/product.py +++ b/src/stapi_fastapi/models/product.py @@ -1,10 +1,16 @@ +from __future__ import annotations + +from copy import deepcopy from enum import Enum -from typing import Literal, Optional +from typing import TYPE_CHECKING, Literal, Optional, Self from pydantic import AnyHttpUrl, BaseModel, Field +from stapi_fastapi.models.opportunity import OpportunityPropertiesBase from stapi_fastapi.models.shared import Link -from stapi_fastapi.types.json_schema_model import JsonSchemaModel + +if TYPE_CHECKING: + from stapi_fastapi.backends.product_backend import ProductBackend class ProviderRole(str, Enum): @@ -20,9 +26,14 @@ class Provider(BaseModel): roles: list[ProviderRole] url: AnyHttpUrl + # redefining init is a hack to get str type to validate for `url`, + # as str is ultimately coerced into an AnyHttpUrl automatically anyway + def __init__(self, url: AnyHttpUrl | str, **kwargs): + super().__init__(url=url, **kwargs) + class Product(BaseModel): - type: Literal["Product"] = "Product" + type_: Literal["Product"] = Field(default="Product", alias="type") conformsTo: list[str] = Field(default_factory=list) id: str title: str = "" @@ -30,11 +41,41 @@ class Product(BaseModel): keywords: list[str] = Field(default_factory=list) license: str providers: list[Provider] = Field(default_factory=list) - links: list[Link] - parameters: JsonSchemaModel + links: list[Link] = Field(default_factory=list) + + # we don't want to include these in the model fields + _constraints: type[OpportunityPropertiesBase] + _backend: ProductBackend + + def __init__( + self, + *args, + backend: ProductBackend, + constraints: type[OpportunityPropertiesBase], + **kwargs, + ) -> None: + super().__init__(*args, **kwargs) + self._backend = backend + self._constraints = constraints + + @property + def backend(self: Self) -> ProductBackend: + return self._backend + + @property + def constraints(self: Self) -> type[OpportunityPropertiesBase]: + return self._constraints + + def with_links(self: Self, links: list[Link] | None = None) -> Self: + if not links: + return self + + new = deepcopy(self) + new.links.extend(links) + return new class ProductsCollection(BaseModel): - type: Literal["ProductCollection"] = "ProductCollection" + type_: Literal["ProductCollection"] = Field("ProductCollection", alias="type") links: list[Link] = Field(default_factory=list) products: list[Product] diff --git a/src/stapi_fastapi/models/shared.py b/src/stapi_fastapi/models/shared.py index c741bd9..f67c4e7 100644 --- a/src/stapi_fastapi/models/shared.py +++ b/src/stapi_fastapi/models/shared.py @@ -1,19 +1,32 @@ -from typing import Any, Optional, Union +from typing import Any, Self -from pydantic import AnyUrl, BaseModel, ConfigDict +from pydantic import ( + AnyUrl, + BaseModel, + ConfigDict, + SerializerFunctionWrapHandler, + model_serializer, +) class Link(BaseModel): href: AnyUrl rel: str - type: Optional[str] = None - title: Optional[str] = None - method: Optional[str] = None - headers: Optional[dict[str, Union[str, list[str]]]] = None + type: str | None = None + title: str | None = None + method: str | None = None + headers: dict[str, str | list[str]] | None = None body: Any = None model_config = ConfigDict(extra="allow") + # redefining init is a hack to get str type to validate for `href`, + # as str is ultimately coerced into an AnyUrl automatically anyway + def __init__(self, href: AnyUrl | str, **kwargs): + super().__init__(href=href, **kwargs) -class HTTPException(BaseModel): - detail: str + # overriding the default serialization to filter None field values from + # dumped json + @model_serializer(mode="wrap", when_used="json") + def serialize(self: Self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + return {k: v for k, v in handler(self).items() if v is not None} diff --git a/src/stapi_fastapi/responses.py b/src/stapi_fastapi/responses.py new file mode 100644 index 0000000..4a6d551 --- /dev/null +++ b/src/stapi_fastapi/responses.py @@ -0,0 +1,7 @@ +from fastapi.responses import JSONResponse + +from stapi_fastapi.constants import TYPE_GEOJSON + + +class GeoJSONResponse(JSONResponse): + media_type = TYPE_GEOJSON diff --git a/src/stapi_fastapi/routers/__init__.py b/src/stapi_fastapi/routers/__init__.py new file mode 100644 index 0000000..3919e6b --- /dev/null +++ b/src/stapi_fastapi/routers/__init__.py @@ -0,0 +1,7 @@ +from .product_router import ProductRouter +from .root_router import RootRouter + +__all__ = [ + "ProductRouter", + "RootRouter", +] diff --git a/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py new file mode 100644 index 0000000..ac2ae4a --- /dev/null +++ b/src/stapi_fastapi/routers/product_router.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Self + +from fastapi import APIRouter, HTTPException, Request, Response, status +from geojson_pydantic.geometries import Geometry + +from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON +from stapi_fastapi.exceptions import ConstraintsException +from stapi_fastapi.models.opportunity import ( + OpportunityCollection, + OpportunityRequest, +) +from stapi_fastapi.models.order import Order +from stapi_fastapi.models.product import Product +from stapi_fastapi.models.shared import Link +from stapi_fastapi.responses import GeoJSONResponse +from stapi_fastapi.types.json_schema_model import JsonSchemaModel + +if TYPE_CHECKING: + from stapi_fastapi.routers import RootRouter + + +class ProductRouter(APIRouter): + def __init__( + self, + product: Product, + root_router: RootRouter, + *args, + **kwargs, + ) -> None: + super().__init__(*args, **kwargs) + self.product = product + self.root_router = root_router + + self.add_api_route( + path="", + endpoint=self.get_product, + name=f"{self.root_router.name}:{self.product.id}:get-product", + methods=["GET"], + summary="Retrieve this product", + ) + + self.add_api_route( + path="/opportunities", + endpoint=self.search_opportunities, + name=f"{self.root_router.name}:{self.product.id}:search-opportunities", + methods=["POST"], + response_class=GeoJSONResponse, + response_model=OpportunityCollection[Geometry, self.product.constraints], + summary="Search Opportunities for the product", + ) + + self.add_api_route( + path="/constraints", + endpoint=self.get_product_constraints, + name=f"{self.root_router.name}:{self.product.id}:get-constraints", + methods=["GET"], + summary="Get constraints for the product", + ) + + self.add_api_route( + path="/order", + endpoint=self.create_order, + name=f"{self.root_router.name}:{self.product.id}:create-order", + methods=["POST"], + response_class=GeoJSONResponse, + status_code=status.HTTP_201_CREATED, + summary="Create an order for the product", + ) + + def get_product(self, request: Request) -> Product: + return self.product.with_links( + links=[ + Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:get-product", + ), + ), + rel="self", + type=TYPE_JSON, + ), + ], + ) + + async def search_opportunities( + self, search: OpportunityRequest, request: Request + ) -> OpportunityCollection: + """ + Explore the opportunities available for a particular set of constraints + """ + try: + opportunities = await self.product.backend.search_opportunities( + self, search, request + ) + except ConstraintsException as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail) + + return OpportunityCollection(features=opportunities) + + async def get_product_constraints(self: Self) -> JsonSchemaModel: + """ + Return supported constraints of a specific product + """ + return self.product.constraints + + async def create_order( + self, payload: OpportunityRequest, request: Request, response: Response + ) -> Order: + """ + Create a new order. + """ + try: + order = await self.product.backend.create_order( + self, + payload, + request, + ) + except ConstraintsException as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.detail) + + location = str(self.root_router.generate_order_href(request, order.id)) + order.links.append(Link(href=location, rel="self", type=TYPE_GEOJSON)) + response.headers["Location"] = location + return order diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py new file mode 100644 index 0000000..fed6635 --- /dev/null +++ b/src/stapi_fastapi/routers/root_router.py @@ -0,0 +1,145 @@ +from typing import Self + +from fastapi import APIRouter, Request +from fastapi.datastructures import URL + +from stapi_fastapi.backends.root_backend import RootBackend +from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON +from stapi_fastapi.models.order import Order, OrderCollection +from stapi_fastapi.models.product import Product, ProductsCollection +from stapi_fastapi.models.root import RootResponse +from stapi_fastapi.models.shared import Link +from stapi_fastapi.responses import GeoJSONResponse +from stapi_fastapi.routers.product_router import ProductRouter + + +class RootRouter(APIRouter): + def __init__( + self, + backend: RootBackend, + name: str = "root", + openapi_endpoint_name="openapi", + docs_endpoint_name="swagger_ui_html", + *args, + **kwargs, + ) -> None: + super().__init__(*args, **kwargs) + self.backend = backend + self.name = name + self.openapi_endpoint_name = openapi_endpoint_name + self.docs_endpoint_name = docs_endpoint_name + + # A dict is used to track the product routers so we can ensure + # idempotentcy in case a product is added multiple times, and also to + # manage clobbering if multiple products with the same product_id are + # added. + self.product_routers: dict[str, ProductRouter] = {} + + self.add_api_route( + "/", + self.get_root, + methods=["GET"], + name=f"{self.name}:root", + tags=["Root"], + ) + + self.add_api_route( + "/products", + self.get_products, + methods=["GET"], + name=f"{self.name}:list-products", + tags=["Product"], + ) + + self.add_api_route( + "/orders", + self.get_orders, + methods=["GET"], + name=f"{self.name}:list-orders", + response_class=GeoJSONResponse, + tags=["Order"], + ) + + self.add_api_route( + "/orders/{order_id}", + self.get_order, + methods=["GET"], + name=f"{self.name}:get-order", + response_class=GeoJSONResponse, + tags=["Orders"], + ) + + def get_root(self, request: Request) -> RootResponse: + return RootResponse( + links=[ + Link( + href=str(request.url_for(f"{self.name}:root")), + rel="self", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(f"{self.name}:list-products")), + rel="products", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(f"{self.name}:list-orders")), + rel="orders", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(self.openapi_endpoint_name)), + rel="service-description", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(self.docs_endpoint_name)), + rel="service-docs", + type="text/html", + ), + ] + ) + + def get_products(self, request: Request) -> ProductsCollection: + return ProductsCollection( + products=[pr.get_product(request) for pr in self.product_routers.values()], + links=[ + Link( + href=str(request.url_for(f"{self.name}:list-products")), + rel="self", + type=TYPE_JSON, + ) + ], + ) + + async def get_orders(self, request: Request) -> OrderCollection: + orders = await self.backend.get_orders(request) + for order in orders: + order.links.append( + Link( + href=str( + request.url_for(f"{self.name}:get-order", order_id=order.id) + ), + rel="self", + type=TYPE_JSON, + ) + ) + + return orders + + async def get_order(self: Self, order_id: str, request: Request) -> Order: + """ + Get details for order with `order_id`. + """ + order = await self.backend.get_order(order_id, request) + order.links.append(Link(href=str(request.url), rel="self", type=TYPE_GEOJSON)) + return order + + def add_product(self: Self, product: Product) -> None: + # Give the include a prefix from the product router + product_router = ProductRouter(product, self) + self.include_router(product_router, prefix=f"/products/{product.id}") + self.product_routers[product.id] = product_router + + def generate_order_href(self: Self, request: Request, order_id: int | str) -> URL: + return request.url_for(f"{self.name}:get-order", order_id=order_id) diff --git a/src/stapi_fastapi/types/datetime_interval.py b/src/stapi_fastapi/types/datetime_interval.py index 3cedc7a..80e9d7f 100644 --- a/src/stapi_fastapi/types/datetime_interval.py +++ b/src/stapi_fastapi/types/datetime_interval.py @@ -10,10 +10,10 @@ ) -def validate_before(value: Any): +def validate_before(value: Any) -> tuple[datetime, datetime]: if isinstance(value, str): start, end = value.split("/", 1) - return (start, end) + return (datetime.fromisoformat(start), datetime.fromisoformat(end)) return value diff --git a/tests/backend/__init__.py b/tests/backend/__init__.py deleted file mode 100644 index a404262..0000000 --- a/tests/backend/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .backend import TestBackend - -__all__ = ["TestBackend"] diff --git a/tests/backend/backend.py b/tests/backends.py similarity index 54% rename from tests/backend/backend.py rename to tests/backends.py index 76e086f..c619e64 100644 --- a/tests/backend/backend.py +++ b/tests/backends.py @@ -1,44 +1,56 @@ -from typing import Mapping from uuid import uuid4 from fastapi import Request +from stapi_fastapi.backends.product_backend import ProductBackend from stapi_fastapi.exceptions import ConstraintsException, NotFoundException from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest -from stapi_fastapi.models.order import Order -from stapi_fastapi.models.product import Product +from stapi_fastapi.models.order import Order, OrderCollection +from stapi_fastapi.routers.product_router import ProductRouter -class TestBackend: - _products: list[Product] = [] - _opportunities: list[Opportunity] = [] - _allowed_payloads: list[OpportunityRequest] = [] - _orders: Mapping[str, Order] = {} +class MockOrderDB(dict[int | str, Order]): + pass - def products(self, request: Request) -> list[Product]: + +class MockRootBackend: + def __init__(self, orders: MockOrderDB) -> None: + self._orders = orders + + async def get_orders(self, request: Request) -> OrderCollection: """ - Return a list of supported products. + Show all orders. """ - return self._products + return list(self._orders.values()) - def product(self, product_id: str, request: Request) -> Product | None: + async def get_order(self, order_id: str, request: Request) -> Order: """ - Return the product identified by `product_id` or `None` if it isn't - supported. + Show details for order with `order_id`. """ try: - return next( - (product for product in self._products if product.id == product_id) - ) - except StopIteration as exc: - raise NotFoundException() from exc + return self._orders[order_id] + except KeyError: + raise NotFoundException() + + +class MockProductBackend(ProductBackend): + def __init__(self, orders: MockOrderDB) -> None: + self._opportunities: list[Opportunity] = [] + self._allowed_payloads: list[OpportunityRequest] = [] + self._orders = orders async def search_opportunities( - self, search: OpportunityRequest, request: Request + self, + product_router: ProductRouter, + search: OpportunityRequest, + request: Request, ) -> list[Opportunity]: return [o.model_copy(update=search.model_dump()) for o in self._opportunities] async def create_order( - self, payload: OpportunityRequest, request: Request + self, + product_router: ProductRouter, + payload: OpportunityRequest, + request: Request, ) -> Order: """ Create a new order. @@ -51,19 +63,10 @@ async def create_order( properties={ "filter": payload.filter, "datetime": payload.datetime, - "product_id": payload.product_id, + "product_id": product_router.product.id, }, links=[], ) self._orders[order.id] = order return order raise ConstraintsException("not allowed") - - async def get_order(self, order_id: str, request: Request) -> Order: - """ - Show details for order with `order_id`. - """ - try: - return self._orders[order_id] - except KeyError: - raise NotFoundException() diff --git a/tests/conftest.py b/tests/conftest.py index 944e8cd..1f2495d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,18 +1,27 @@ from collections.abc import Iterator -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta, timezone from typing import Callable from urllib.parse import urljoin +from uuid import uuid4 import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from geojson_pydantic import Point -from pydantic import BaseModel -from stapi_fastapi.api import StapiRouter -from stapi_fastapi.models.opportunity import Opportunity, OpportunityRequest +from geojson_pydantic.types import Position2D +from stapi_fastapi.models.opportunity import ( + Opportunity, + OpportunityPropertiesBase, + OpportunityRequest, +) from stapi_fastapi.models.product import Product, Provider, ProviderRole +from stapi_fastapi.routers.root_router import RootRouter -from .backend import TestBackend +from .backends import MockOrderDB, MockProductBackend, MockRootBackend + + +class TestSpotlightProperties(OpportunityPropertiesBase): + off_nadir: int @pytest.fixture(scope="session") @@ -21,20 +30,49 @@ def base_url() -> Iterator[str]: @pytest.fixture -def stapi_backend() -> Iterator[TestBackend]: - yield TestBackend() +def order_db() -> MockOrderDB: + return MockOrderDB() @pytest.fixture -def stapi_client(stapi_backend, base_url: str) -> Iterator[TestClient]: - app = FastAPI() +def product_backend(order_db: MockOrderDB) -> MockProductBackend: + return MockProductBackend(order_db) - app.include_router( - StapiRouter(backend=stapi_backend).router, - prefix="", + +@pytest.fixture +def root_backend(order_db: MockOrderDB) -> MockRootBackend: + return MockRootBackend(order_db) + + +@pytest.fixture +def mock_product_test_spotlight( + product_backend: MockProductBackend, mock_provider: Provider +) -> Product: + """Fixture for a mock Test Spotlight product.""" + return Product( + id="test-spotlight", + title="Test Spotlight Product", + description="Test product for test spotlight", + license="CC-BY-4.0", + keywords=["test", "satellite"], + providers=[mock_provider], + links=[], + constraints=TestSpotlightProperties, + backend=product_backend, ) - yield TestClient(app, base_url=f"{base_url}") + +@pytest.fixture +def stapi_client( + root_backend, mock_product_test_spotlight, base_url: str +) -> Iterator[TestClient]: + root_router = RootRouter(root_backend) + root_router.add_product(mock_product_test_spotlight) + app = FastAPI() + app.include_router(root_router, prefix="") + + with TestClient(app, base_url=f"{base_url}") as client: + yield client @pytest.fixture(scope="session") @@ -49,53 +87,51 @@ def url_for(value: str) -> str: @pytest.fixture -def products() -> Iterator[list[Product]]: - class Parameters(BaseModel): - pass - - yield [ - Product( - id="mock:standard", - description="Mock backend's standard product", - license="CC0-1.0", - providers=[ - Provider( - name="ACME", - roles=[ - ProviderRole.licensor, - ProviderRole.producer, - ProviderRole.processor, - ProviderRole.host, - ], - url="http://acme.example.com", - ) - ], - parameters=Parameters, - links=[], - ) - ] +def products(mock_product_test_spotlight: Product) -> list[Product]: + return [mock_product_test_spotlight] @pytest.fixture -def opportunities(products: list[Product]) -> Iterator[list[Opportunity]]: - yield [ +def mock_provider() -> Provider: + return Provider( + name="Test Provider", + description="A provider for Test data", + roles=[ProviderRole.producer], # Example role + url="https://test-provider.example.com", # Must be a valid URL + ) + + +@pytest.fixture +def mock_test_spotlight_opportunities() -> list[Opportunity]: + """Fixture to create mock data for Opportunities for `test-spotlight-1`.""" + now = datetime.now(timezone.utc) # Use timezone-aware datetime + start = now + end = start + timedelta(days=5) + + # Create a list of mock opportunities for the given product + return [ Opportunity( - geometry=Point(type="Point", coordinates=[13.4, 52.5]), - properties={ - "product_id": products[0].id, - "datetime": (datetime.now(UTC), datetime.now(UTC)), - "filter": {}, - }, - ) + id=str(uuid4()), + type="Feature", + geometry=Point( + type="Point", + coordinates=Position2D(longitude=0.0, latitude=0.0), + ), + properties=TestSpotlightProperties( + datetime=(start, end), + off_nadir=20, + ), + ), ] @pytest.fixture -def allowed_payloads(products: list[Product]) -> Iterator[list[OpportunityRequest]]: - yield [ +def allowed_payloads() -> list[OpportunityRequest]: + return [ OpportunityRequest( - geometry=Point(type="Point", coordinates=[13.4, 52.5]), - product_id=products[0].id, + geometry=Point( + type="Point", coordinates=Position2D(longitude=13.4, latitude=52.5) + ), datetime=(datetime.now(UTC), datetime.now(UTC)), filter={}, ), diff --git a/tests/opportunity_test.py b/tests/opportunity_test.py index 7fe784e..2e67d7f 100644 --- a/tests/opportunity_test.py +++ b/tests/opportunity_test.py @@ -1,46 +1,57 @@ from datetime import UTC, datetime, timedelta +from typing import List -from fastapi import status +import pytest from fastapi.testclient import TestClient from stapi_fastapi.models.opportunity import Opportunity, OpportunityCollection -from stapi_fastapi.models.product import Product -from .backend import TestBackend +from .backends import MockProductBackend +from .datetime_interval_test import rfc3339_strftime +@pytest.mark.parametrize("product_id", ["test-spotlight"]) def test_search_opportunities_response( - products: list[Product], - opportunities: list[Opportunity], - stapi_backend: TestBackend, + product_id: str, + mock_test_spotlight_opportunities: List[Opportunity], + product_backend: MockProductBackend, stapi_client: TestClient, -): - stapi_backend._products = products - stapi_backend._opportunities = opportunities +) -> None: + product_backend._opportunities = mock_test_spotlight_opportunities now = datetime.now(UTC) start = now end = start + timedelta(days=5) + format = "%Y-%m-%dT%H:%M:%S.%f%z" + start_string = rfc3339_strftime(start, format) + end_string = rfc3339_strftime(end, format) - res = stapi_client.post( - "/opportunities", - json={ - "geometry": { - "type": "Point", - "coordinates": [0, 0], - }, - "product_id": products[0].id, - "datetime": f"{start.isoformat()}/{end.isoformat()}", - "filter": { - "op": "and", - "args": [ - {"op": ">", "args": [{"property": "off_nadir"}, 0]}, - {"op": "<", "args": [{"property": "off_nadir"}, 45]}, - ], - }, + # Prepare the request payload + request_payload = { + "geometry": { + "type": "Point", + "coordinates": [0, 0], }, - ) - assert res.status_code == status.HTTP_200_OK - assert res.headers["Content-Type"] == "application/geo+json" - response = OpportunityCollection(**res.json()) + "datetime": f"{start_string}/{end_string}", + "filter": { + "op": "and", + "args": [ + {"op": ">", "args": [{"property": "off_nadir"}, 0]}, + {"op": "<", "args": [{"property": "off_nadir"}, 45]}, + ], + }, + } + + # Construct the endpoint URL using the `product_name` parameter + url = f"/products/{product_id}/opportunities" + + # Use POST method to send the payload + response = stapi_client.post(url, json=request_payload) + + # Validate response status and structure + assert response.status_code == 200, f"Failed for product: {product_id}" + _json = response.json() - assert len(response.features) > 0 + try: + OpportunityCollection(**_json) + except Exception as _: + pytest.fail("response is not an opportunity collection") diff --git a/tests/order_test.py b/tests/order_test.py index 4a94286..56849d6 100644 --- a/tests/order_test.py +++ b/tests/order_test.py @@ -1,13 +1,12 @@ from datetime import UTC, datetime, timedelta -from typing import Generator +import pytest from fastapi import status from fastapi.testclient import TestClient from httpx import Response -from pytest import fixture from stapi_fastapi.models.opportunity import OpportunityRequest -from .backend import TestBackend +from .backends import MockProductBackend from .utils import find_link NOW = datetime.now(UTC) @@ -15,44 +14,49 @@ END = START + timedelta(days=5) -@fixture +@pytest.fixture def new_order_response( - stapi_backend: TestBackend, + product_id: str, + product_backend: MockProductBackend, stapi_client: TestClient, allowed_payloads: list[OpportunityRequest], -) -> Generator[Response, None, None]: - stapi_backend._allowed_payloads = allowed_payloads +) -> Response: + product_backend._allowed_payloads = allowed_payloads res = stapi_client.post( - "/orders", + f"products/{product_id}/order", json=allowed_payloads[0].model_dump(), ) assert res.status_code == status.HTTP_201_CREATED assert res.headers["Content-Type"] == "application/geo+json" - yield res + return res -def test_new_order_location_header_matches_self_link(new_order_response: Response): +@pytest.mark.parametrize("product_id", ["test-spotlight"]) +def test_new_order_location_header_matches_self_link( + new_order_response: Response, +) -> None: order = new_order_response.json() - assert new_order_response.headers["Location"] == str( - find_link(order["links"], "self")["href"] - ) + link = find_link(order["links"], "self") + assert link + assert new_order_response.headers["Location"] == str(link["href"]) -@fixture +@pytest.fixture def get_order_response( stapi_client: TestClient, new_order_response: Response -) -> Generator[Response, None, None]: +) -> Response: order_id = new_order_response.json()["id"] res = stapi_client.get(f"/orders/{order_id}") assert res.status_code == status.HTTP_200_OK assert res.headers["Content-Type"] == "application/geo+json" - yield res + return res -def test_get_order_properties(get_order_response: Response, allowed_payloads): +@pytest.mark.parametrize("product_id", ["test-spotlight"]) +def test_get_order_properties(get_order_response: Response, allowed_payloads) -> None: order = get_order_response.json() assert order["geometry"] == { diff --git a/tests/product_test.py b/tests/product_test.py index 690074d..1d7c81e 100644 --- a/tests/product_test.py +++ b/tests/product_test.py @@ -1,12 +1,8 @@ -from warnings import warn - +import pytest from fastapi import status from fastapi.testclient import TestClient -from stapi_fastapi.models.product import Product -from .backend import TestBackend from .utils import find_link -from .warnings import StapiSpecWarning def test_products_response(stapi_client: TestClient): @@ -21,23 +17,33 @@ def test_products_response(stapi_client: TestClient): assert isinstance(data["products"], list) +@pytest.mark.parametrize("product_id", ["test-spotlight"]) def test_product_response_self_link( - products: list[Product], - stapi_backend: TestBackend, + product_id: str, stapi_client: TestClient, url_for, ): - stapi_backend._products = products + res = stapi_client.get(f"/products/{product_id}") + assert res.status_code == status.HTTP_200_OK + assert res.headers["Content-Type"] == "application/json" - res = stapi_client.get("/products/mock:standard") + data = res.json() + link = find_link(data["links"], "self") + assert link, "GET /products Link[rel=self] should exist" + assert link["type"] == "application/json" + assert link["href"] == url_for(f"/products/{product_id}") + +@pytest.mark.parametrize("product_id", ["test-spotlight"]) +def test_product_constraints_response( + product_id: str, + stapi_client: TestClient, +): + res = stapi_client.get(f"/products/{product_id}/constraints") assert res.status_code == status.HTTP_200_OK assert res.headers["Content-Type"] == "application/json" data = res.json() - link = find_link(data["links"], "self") - if link is None: - warn(StapiSpecWarning("GET /products Link[rel=self] should exist")) - else: - assert link["type"] == "application/json" - assert link["href"] == url_for("/products/mock:standard") + assert "properties" in data + assert "datetime" in data["properties"] + assert "off_nadir" in data["properties"] diff --git a/tests/root_test.py b/tests/root_test.py index e1257df..5fa42bf 100644 --- a/tests/root_test.py +++ b/tests/root_test.py @@ -1,24 +1,10 @@ -from warnings import warn - from fastapi import status from fastapi.testclient import TestClient -from pytest import fixture from .utils import find_link -from .warnings import StapiSpecWarning -@fixture -def data(stapi_client: TestClient): - res = stapi_client.get("/") - - assert res.status_code == status.HTTP_200_OK - assert res.headers["Content-Type"] == "application/json" - - yield res.json() - - -def test_root(stapi_client: TestClient, url_for): +def test_root(stapi_client: TestClient, url_for) -> None: res = stapi_client.get("/") assert res.status_code == status.HTTP_200_OK @@ -27,23 +13,16 @@ def test_root(stapi_client: TestClient, url_for): data = res.json() link = find_link(data["links"], "self") - if link is None: - warn(StapiSpecWarning("GET / Link[rel=self] should exist")) - else: - assert link["type"] == "application/json" - assert link["href"] == url_for("/") + assert link, "GET / Link[rel=self] should exist" + assert link["type"] == "application/json" + assert link["href"] == url_for("/") link = find_link(data["links"], "service-description") - if link is None: - warn(StapiSpecWarning("GET / Link[rel=service-description] should exist")) - - else: - assert link["type"] == "application/json" - assert str(link["href"]) == url_for("/openapi.json") + assert link, "GET / Link[rel=service-description] should exist" + assert link["type"] == "application/json" + assert str(link["href"]) == url_for("/openapi.json") link = find_link(data["links"], "service-docs") - if link is None: - warn(StapiSpecWarning("GET / Link[rel=service-docs] should exist")) - else: - assert link["type"] == "text/html" - assert str(link["href"]) == url_for("/docs") + assert link, "GET / Link[rel=service-docs] should exist" + assert link["type"] == "text/html" + assert str(link["href"]) == url_for("/docs") diff --git a/tests/utils.py b/tests/utils.py index fdb3342..20947d4 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,2 +1,7 @@ -def find_link(links: list[dict], rel: str) -> dict | None: +from typing import Any + +link_dict = dict[str, Any] + + +def find_link(links: list[link_dict], rel: str) -> link_dict | None: return next((link for link in links if link["rel"] == rel), None) diff --git a/tests/warnings.py b/tests/warnings.py deleted file mode 100644 index a8b7062..0000000 --- a/tests/warnings.py +++ /dev/null @@ -1,2 +0,0 @@ -class StapiSpecWarning(Warning): - pass