diff --git a/CHANGES.md b/CHANGES.md index 940b7b844..a41037637 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,7 @@ * switch from `fastapi` to `fastapi-slim` to avoid installing unwanted dependencies. ([#687](https://github.com/stac-utils/stac-fastapi/pull/687)) * replace Enum with `Literal` for `FilterLang`. ([#686](https://github.com/stac-utils/stac-fastapi/pull/686)) +* Added `collection-search` extension ### Removed diff --git a/stac_fastapi/api/stac_fastapi/api/config.py b/stac_fastapi/api/stac_fastapi/api/config.py index 3918421ff..81b148293 100644 --- a/stac_fastapi/api/stac_fastapi/api/config.py +++ b/stac_fastapi/api/stac_fastapi/api/config.py @@ -18,6 +18,7 @@ class ApiExtensions(enum.Enum): query = "query" sort = "sort" transaction = "transaction" + collection_search = "collection-search" class AddOns(enum.Enum): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py index 74f15ed0a..be608101c 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py @@ -1,5 +1,4 @@ """stac_api.extensions.core module.""" - from .context import ContextExtension from .fields import FieldsExtension from .filter import FilterExtension @@ -7,6 +6,7 @@ from .query import QueryExtension from .sort import SortExtension from .transaction import TransactionExtension +from .collectionSearch import CollectionSearchExtension __all__ = ( "ContextExtension", @@ -17,4 +17,5 @@ "SortExtension", "TokenPaginationExtension", "TransactionExtension", + "CollectionSearchExtension", ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/__init__.py new file mode 100644 index 000000000..21f35e8ca --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/__init__.py @@ -0,0 +1,6 @@ +"""Filter extension module.""" + + +from .collectionSearch import CollectionSearchExtension + +__all__ = ["CollectionSearchExtension"] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/collectionSearch.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/collectionSearch.py new file mode 100644 index 000000000..39723fb26 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/collectionSearch.py @@ -0,0 +1,124 @@ +# encoding: utf-8 +"""Collection Search Extension.""" +from enum import Enum +from typing import List, Type, Union + +import attr +from fastapi import APIRouter, FastAPI +from stac_pydantic.api.collections import Collections +from starlette.responses import Response + +from stac_fastapi.api.models import JSONSchemaResponse +from stac_fastapi.api.routes import create_async_endpoint +from stac_fastapi.types.config import ApiSettings +from stac_fastapi.types.core import ( + AsyncBaseCollectionSearchClient, + BaseCollectionSearchClient, +) +from stac_fastapi.types.extension import ApiExtension +from stac_fastapi.types.search import ( + BaseCollectionSearchGetRequest, + BaseCollectionSearchPostRequest, +) + +from .request import ( + CollectionSearchExtensionGetRequest, + CollectionSearchExtensionPostRequest, +) + + +class CollectionSearchConformanceClasses(str, Enum): + """Conformance classes for the Collection Search extension. + + See + https://github.com/stac-api-extensions/collection-search + """ + + CORE = "https://api.stacspec.org/v1.0.0-rc.1/core" + COLLECTION_SEARCH = "https://api.stacspec.org/v1.0.0-rc.1/collection-search" + SIMPLE_QUERY = "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query" + + +@attr.s +class CollectionSearchExtension(ApiExtension): + """CollectionSearch Extension. + + The collection search extension adds two endpoints which allow searching of + collections via GET and POST, to avoid conflict with /collections endpoints + those used here are: + GET /collection-search + POST /collection-search + + https://github.com/stac-api-extensions/collection-search + + Attributes: + search_get_request_model: Get request model for collection search + search_post_request_model: Post request model for collection search + client: Collection Search endpoint logic + conformance_classes: Conformance classes provided by the extension + """ + + GET = CollectionSearchExtensionGetRequest + POST = CollectionSearchExtensionPostRequest + + client: Union[AsyncBaseCollectionSearchClient, BaseCollectionSearchClient] = attr.ib( + factory=BaseCollectionSearchClient + ) + settings: ApiSettings = attr.ib(default=ApiSettings()) + + collection_search_get_request_model: Type[BaseCollectionSearchGetRequest] = attr.ib( + default=BaseCollectionSearchGetRequest + ) + collection_search_post_request_model: Type[BaseCollectionSearchPostRequest] = attr.ib( + default=BaseCollectionSearchPostRequest + ) + + conformance_classes: List[str] = attr.ib( + default=[ + CollectionSearchConformanceClasses.CORE, + CollectionSearchConformanceClasses.COLLECTION_SEARCH, + CollectionSearchConformanceClasses.SIMPLE_QUERY, + ] + ) + router: APIRouter = attr.ib(factory=APIRouter) + response_class: Type[Response] = attr.ib(default=JSONSchemaResponse) + + def register(self, app: FastAPI) -> None: + """Register the extension with a FastAPI application. + + Args: + app: target FastAPI application. + + Returns: + None + """ + self.router.prefix = app.state.router_prefix + self.router.add_api_route( + name="Collection Search", + path="/collection-search", + response_model=Collections if self.settings.enable_response_models else None, + response_class=self.response_class, + response_model_exclude_unset=True, + response_model_exclude_none=True, + methods=["GET"], + endpoint=create_async_endpoint( + self.client.get_collection_search, + self.collection_search_get_request_model, + ), + ) + + self.router.add_api_route( + name="Collection Search", + path="/collection-search", + response_model=Collections if self.settings.enable_response_models else None, + response_class=self.response_class, + response_model_exclude_unset=True, + response_model_exclude_none=True, + methods=["POST"], + endpoint=create_async_endpoint( + self.client.post_collection_search, + self.collection_search_post_request_model, + ), + ) + + app.include_router(self.router, tags=["Collection Search Extension"]) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/request.py new file mode 100644 index 000000000..f159d6a87 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/request.py @@ -0,0 +1,28 @@ +"""Collection Search extension request models.""" + +from enum import Enum +from typing import Any, Dict, Optional, List + +import attr +from pydantic import BaseModel, Field +from stac_pydantic.shared import BBox + +from stac_fastapi.types.search import APIRequest, Limit, str2bbox + +from stac_fastapi.types.rfc3339 import DateTimeType, str_to_interval + +@attr.s +class CollectionSearchExtensionGetRequest(APIRequest): + """Collection Search extension GET request model.""" + + bbox: Optional[BBox] = attr.ib(default=None, converter=str2bbox) + datetime: Optional[DateTimeType] = attr.ib(default=None, converter=str_to_interval) + limit: Optional[int] = attr.ib(default=10) + + +class CollectionSearchExtensionPostRequest(BaseModel): + """Collection Search extension POST request model.""" + + bbox: Optional[BBox] + datetime: Optional[DateTimeType] + limit: Optional[Limit] = Field(default=10) diff --git a/stac_fastapi/extensions/tests/test_collection_search.py b/stac_fastapi/extensions/tests/test_collection_search.py new file mode 100644 index 000000000..9703b4dc6 --- /dev/null +++ b/stac_fastapi/extensions/tests/test_collection_search.py @@ -0,0 +1,122 @@ +from typing import Iterator + +import pytest +from stac_pydantic.api.collections import Collections +from stac_pydantic.shared import BBox +from starlette.testclient import TestClient + +from stac_fastapi.api.app import StacApi +from stac_fastapi.extensions.core import CollectionSearchExtension +from stac_fastapi.types.config import ApiSettings +from stac_fastapi.types.core import BaseCollectionSearchClient, BaseCoreClient +from stac_fastapi.types.rfc3339 import DateTimeType +from stac_fastapi.types.search import ( + BaseCollectionSearchGetRequest, + BaseCollectionSearchPostRequest, +) +from stac_fastapi.types.stac import Item, ItemCollection + + +class DummyCoreClient(BaseCoreClient): + def all_collections(self, *args, **kwargs): + raise NotImplementedError + + def get_collection(self, *args, **kwargs): + raise NotImplementedError + + def get_item(self, *args, **kwargs): + raise NotImplementedError + + def get_search(self, *args, **kwargs): + raise NotImplementedError + + def post_search(self, *args, **kwargs): + raise NotImplementedError + + def item_collection(self, *args, **kwargs): + raise NotImplementedError + + +class DummyCollectionSearchClient(BaseCollectionSearchClient): + """Defines a pattern for implementing the STAC collection search extension.""" + + def get_collection_search( + self, + request: BaseCollectionSearchGetRequest, + bbox: BBox, + datetime: DateTimeType, + limit: int, + ): + return Collections() + + def post_collection_search( + self, request: BaseCollectionSearchPostRequest, *args, **kwargs + ): + return Collections() + + +def test_get_collection_search(client: TestClient) -> None: + get_search = client.get("/collection-search", params={"limit": 10}) + assert get_search.status_code == 200, get_search.text + Collections(**get_search.json()) + + +def test_post_collection_search(client: TestClient) -> None: + post_search = client.post( + "/collection-search", + json={"limit": 10}, + ) + assert post_search.status_code == 200, post_search.text + Collections(**post_search.json()) + + +@pytest.fixture +def client( + core_client: DummyCoreClient, collection_search_client: DummyCollectionSearchClient +) -> Iterator[TestClient]: + settings = ApiSettings() + api = StacApi( + settings=settings, + client=core_client, + extensions=[ + CollectionSearchExtension(client=collection_search_client, settings=settings), + ], + ) + with TestClient(api.app) as client: + yield client + + +@pytest.fixture +def core_client() -> DummyCoreClient: + return DummyCoreClient() + + +@pytest.fixture +def collection_search_client() -> DummyCollectionSearchClient: + return DummyCollectionSearchClient() + + +@pytest.fixture +def item_collection(item: Item) -> ItemCollection: + return { + "type": "FeatureCollection", + "features": [item], + "links": [], + "context": None, + } + + +@pytest.fixture +def item() -> Item: + return { + "type": "Feature", + "stac_version": "1.0.0", + "stac_extensions": [], + "id": "test_item", + "geometry": {"type": "Point", "coordinates": [-105, 40]}, + "bbox": [-105, 40, -105, 40], + "properties": {}, + "links": [], + "assets": {}, + "collection": "test_collection", + } diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index fdf020b08..21725bcb1 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -20,7 +20,10 @@ from stac_fastapi.types.extension import ApiExtension from stac_fastapi.types.requests import get_base_url from stac_fastapi.types.rfc3339 import DateTimeType -from stac_fastapi.types.search import BaseSearchPostRequest +from stac_fastapi.types.search import ( + BaseCollectionSearchPostRequest, + BaseSearchPostRequest, +) NumType = Union[float, int] StacType = Dict[str, Any] @@ -792,3 +795,79 @@ def get_queryables( "description": "Queryable names for the example STAC API Item Search filter.", "properties": {}, } + + +@attr.s +class AsyncBaseCollectionSearchClient(abc.ABC): + """Defines a pattern for implementing the STAC Collection Search extension.""" + + @abc.abstractmethod + async def post_collection_search( + self, search_request: BaseCollectionSearchPostRequest, **kwargs + ) -> stac.ItemCollection: + """Cross catalog search (POST) of collections. + + Called with `POST /collection-search`. + + Args: + search_request: search request parameters. + + Returns: + A tuple of (collections, next pagination token if any). + """ + ... + + @abc.abstractmethod + async def get_collection_search( + self, + bbox: Optional[BBox] = None, + datetime: Optional[DateTimeType] = None, + limit: Optional[int] = 10, + **kwargs, + ) -> stac.Collections: + """Cross catalog search (GET) of collections. + + Called with `GET /collection-search`. + + Returns: + A tuple of (collections, next pagination token if any). + """ + ... + + +@attr.s +class BaseCollectionSearchClient(abc.ABC): + """Defines a pattern for implementing the STAC Collection Search extension.""" + + @abc.abstractmethod + def post_collection_search( + self, search_request: BaseCollectionSearchPostRequest, **kwargs + ) -> stac.Collections: + """Cross catalog search (POST) of collections. + + Called with `POST /collection-search`. + + Args: + search_request: search request parameters. + + Returns: + A tuple of (collections, next pagination token if any). + """ + ... + + @abc.abstractmethod + def get_collection_search( + self, + bbox: Optional[BBox] = None, + datetime: Optional[DateTimeType] = None, + limit: Optional[int] = 10, + **kwargs, + ) -> stac.Collections: + """Cross catalog search (GET) of collections. + + Called with `GET /collection-search`. + + Returns: + A tuple of (collections, next pagination token if any). + """ + ... diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index cf6647340..e001db35b 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -68,3 +68,18 @@ class BaseSearchPostRequest(Search): """Base arguments for POST Request.""" limit: Optional[Limit] = 10 + + +@attr.s +class BaseCollectionSearchGetRequest(APIRequest): + """Base arguments for Collection Search GET Request.""" + + bbox: Optional[BBox] = attr.ib(default=None, converter=str2bbox) + datetime: Optional[DateTimeType] = attr.ib(default=None, converter=str_to_interval) + limit: Optional[int] = attr.ib(default=10) + + +class BaseCollectionSearchPostRequest(Search): + """Base arguments for Collection Search POST Request.""" + + limit: Optional[Limit] = 10