From 22aa82136944fce608f0152980851c9ee9218776 Mon Sep 17 00:00:00 2001 From: tjellicoe Date: Fri, 17 May 2024 09:02:55 +0100 Subject: [PATCH 1/4] Implemented collection-search extension --- stac_fastapi/api/stac_fastapi/api/config.py | 1 + .../stac_fastapi/extensions/core/__init__.py | 3 +- .../core/collectionSearch/__init__.py | 6 + .../core/collectionSearch/collectionSearch.py | 109 ++++++++++++++++++ .../core/collectionSearch/request.py | 28 +++++ stac_fastapi/types/stac_fastapi/types/core.py | 77 ++++++++++++- .../types/stac_fastapi/types/search.py | 15 +++ 7 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/__init__.py create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/collectionSearch.py create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/request.py 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..963d5dcfd --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/collectionSearch.py @@ -0,0 +1,109 @@ +# encoding: utf-8 +"""Collection Search Extension.""" +from enum import Enum +from typing import List, Type, Union + +import attr +from fastapi import APIRouter, FastAPI +from starlette.responses import Response, JSONResponse +from stac_pydantic.api.collections import Collections + +from stac_fastapi.api.routes import create_async_endpoint +from stac_fastapi.types.core import AsyncCollectionSearchClient, CollectionSearchClient +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 + + collection_search_get_request_model: Type[BaseCollectionSearchGetRequest] = attr.ib( + default=BaseCollectionSearchGetRequest + ) + collection_search_post_request_model: Type[BaseCollectionSearchPostRequest] = attr.ib( + default=BaseCollectionSearchPostRequest + ) + + client: Union[AsyncCollectionSearchClient, CollectionSearchClient] = attr.ib( + factory=CollectionSearchClient + ) + + 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=JSONResponse) + + + 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=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=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/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index fdf020b08..8c5ff0058 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -20,7 +20,7 @@ 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 BaseSearchPostRequest, BaseCollectionSearchPostRequest NumType = Union[float, int] StacType = Dict[str, Any] @@ -792,3 +792,78 @@ def get_queryables( "description": "Queryable names for the example STAC API Item Search filter.", "properties": {}, } + +@attr.s +class AsyncCollectionSearchClient(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 CollectionSearchClient(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). + """ + ... \ No newline at end of file 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 From 0cc1c28ebdbce5ff93809b630f02cf038f0dd843 Mon Sep 17 00:00:00 2001 From: tjellicoe Date: Fri, 17 May 2024 09:04:56 +0100 Subject: [PATCH 2/4] Updated changelog --- CHANGES.md | 1 + 1 file changed, 1 insertion(+) 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 From 95505f484f13cc072fdf0d741ed0f0fa94c23c49 Mon Sep 17 00:00:00 2001 From: tjellicoe Date: Fri, 17 May 2024 10:36:27 +0100 Subject: [PATCH 3/4] Updated to support collection search extension including tests --- .../core/collectionSearch/collectionSearch.py | 51 ++++++---- .../tests/test_collection_search.py | 92 +++++++++++++++++++ stac_fastapi/types/stac_fastapi/types/core.py | 12 ++- 3 files changed, 133 insertions(+), 22 deletions(-) create mode 100644 stac_fastapi/extensions/tests/test_collection_search.py diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/collectionSearch.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/collectionSearch.py index 963d5dcfd..39723fb26 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/collectionSearch.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/collectionSearch.py @@ -5,15 +5,27 @@ import attr from fastapi import APIRouter, FastAPI -from starlette.responses import Response, JSONResponse 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.core import AsyncCollectionSearchClient, CollectionSearchClient +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 stac_fastapi.types.search import ( + BaseCollectionSearchGetRequest, + BaseCollectionSearchPostRequest, +) + +from .request import ( + CollectionSearchExtensionGetRequest, + CollectionSearchExtensionPostRequest, +) -from .request import CollectionSearchExtensionGetRequest, CollectionSearchExtensionPostRequest class CollectionSearchConformanceClasses(str, Enum): """Conformance classes for the Collection Search extension. @@ -26,12 +38,13 @@ class CollectionSearchConformanceClasses(str, Enum): 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 + 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 @@ -44,21 +57,22 @@ class CollectionSearchExtension(ApiExtension): 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 ) - - client: Union[AsyncCollectionSearchClient, CollectionSearchClient] = attr.ib( - factory=CollectionSearchClient - ) - + conformance_classes: List[str] = attr.ib( default=[ CollectionSearchConformanceClasses.CORE, @@ -67,8 +81,7 @@ class CollectionSearchExtension(ApiExtension): ] ) router: APIRouter = attr.ib(factory=APIRouter) - response_class: Type[Response] = attr.ib(default=JSONResponse) - + response_class: Type[Response] = attr.ib(default=JSONSchemaResponse) def register(self, app: FastAPI) -> None: """Register the extension with a FastAPI application. @@ -83,26 +96,28 @@ def register(self, app: FastAPI) -> None: self.router.add_api_route( name="Collection Search", path="/collection-search", - response_model=None, + 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.client.get_collection_search, + self.collection_search_get_request_model, ), ) self.router.add_api_route( name="Collection Search", path="/collection-search", - response_model=None, + 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 + self.client.post_collection_search, + self.collection_search_post_request_model, ), ) 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..591adefd8 --- /dev/null +++ b/stac_fastapi/extensions/tests/test_collection_search.py @@ -0,0 +1,92 @@ +from typing import Iterator + +import pytest +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.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 transaction extension.""" + + def get_collection_search(self, *args, **kwargs): + return NotImplementedError + + def post_collection_search(self, *args, **kwargs): + return NotImplementedError + + +@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 8c5ff0058..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, BaseCollectionSearchPostRequest +from stac_fastapi.types.search import ( + BaseCollectionSearchPostRequest, + BaseSearchPostRequest, +) NumType = Union[float, int] StacType = Dict[str, Any] @@ -793,8 +796,9 @@ def get_queryables( "properties": {}, } + @attr.s -class AsyncCollectionSearchClient(abc.ABC): +class AsyncBaseCollectionSearchClient(abc.ABC): """Defines a pattern for implementing the STAC Collection Search extension.""" @abc.abstractmethod @@ -832,7 +836,7 @@ async def get_collection_search( @attr.s -class CollectionSearchClient(abc.ABC): +class BaseCollectionSearchClient(abc.ABC): """Defines a pattern for implementing the STAC Collection Search extension.""" @abc.abstractmethod @@ -866,4 +870,4 @@ def get_collection_search( Returns: A tuple of (collections, next pagination token if any). """ - ... \ No newline at end of file + ... From 09110bf5e20912cc1948b96b3dd4a1eea4e10f1d Mon Sep 17 00:00:00 2001 From: tjellicoe Date: Fri, 17 May 2024 11:49:32 +0100 Subject: [PATCH 4/4] Updated tests --- .../tests/test_collection_search.py | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/stac_fastapi/extensions/tests/test_collection_search.py b/stac_fastapi/extensions/tests/test_collection_search.py index 591adefd8..9703b4dc6 100644 --- a/stac_fastapi/extensions/tests/test_collection_search.py +++ b/stac_fastapi/extensions/tests/test_collection_search.py @@ -1,12 +1,19 @@ 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 @@ -31,13 +38,36 @@ def item_collection(self, *args, **kwargs): class DummyCollectionSearchClient(BaseCollectionSearchClient): - """Defines a pattern for implementing the STAC transaction extension.""" + """Defines a pattern for implementing the STAC collection search extension.""" - def get_collection_search(self, *args, **kwargs): - return NotImplementedError + def get_collection_search( + self, + request: BaseCollectionSearchGetRequest, + bbox: BBox, + datetime: DateTimeType, + limit: int, + ): + return Collections() - def post_collection_search(self, *args, **kwargs): - return NotImplementedError + 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