Repository: stac-utils/stac-fastapi
Pull request #736 (2024-07-19 14:52:59)

Feature/add collection search extension V2
simpler version of #681 

This PR doesn't add the`/collections - POST` endpoint because:
- it's not mandatory in the spec 
- it will conflict with the transaction extension 😬 

We could in another PR show (in the documentation) how to create a `/collections - POST`  which support both `search and transaction`


cc @tjellicoe-tpzuk

vincentsarago: this is ready for review

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 939471b..fbc15e5 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -7,8 +7,8 @@
 * add more openapi metadata in input models [#734](https://github.com/stac-utils/stac-fastapi/pull/734)
 
 ### Added
-
-* Add Free-text Extension to third party extensions ([#655](https://github.com/stac-utils/stac-fastapi/pull/655))
+* Add Free-text Extension ([#655](https://github.com/stac-utils/stac-fastapi/pull/655))
+* Add Collection-Search Extension ([#736](https://github.com/stac-utils/stac-fastapi/pull/736))
 
 ## [3.0.0b2] - 2024-07-09
 
diff --git a/stac_fastapi/api/stac_fastapi/api/config.py b/stac_fastapi/api/stac_fastapi/api/config.py
index 74a1c73..275159a 100644
--- a/stac_fastapi/api/stac_fastapi/api/config.py
+++ b/stac_fastapi/api/stac_fastapi/api/config.py
@@ -19,6 +19,7 @@ class ApiExtensions(enum.Enum):
     sort = "sort"
     transaction = "transaction"
     aggregation = "aggregation"
+    collection_search = "collection-search"
     free_text = "free-text"
 
 
diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py
index fe8b664..385bd90 100644
--- a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py
+++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py
@@ -1,6 +1,7 @@
 """stac_api.extensions.core module."""
 
 from .aggregation import AggregationExtension
+from .collection_search import CollectionSearchExtension
 from .context import ContextExtension
 from .fields import FieldsExtension
 from .filter import FilterExtension
@@ -22,4 +23,5 @@ __all__ = (
     "SortExtension",
     "TokenPaginationExtension",
     "TransactionExtension",
+    "CollectionSearchExtension",
 )
diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/__init__.py
new file mode 100644
index 0000000..f919491
--- /dev/null
+++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/__init__.py
@@ -0,0 +1,5 @@
+"""Collection-Search extension module."""
+
+from .collection_search import CollectionSearchExtension, ConformanceClasses
+
+__all__ = ["CollectionSearchExtension", "ConformanceClasses"]
diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/collection_search.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/collection_search.py
new file mode 100644
index 0000000..aac8120
--- /dev/null
+++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/collection_search.py
@@ -0,0 +1,66 @@
+"""Collection-Search extension."""
+
+from enum import Enum
+from typing import List, Optional
+
+import attr
+from fastapi import FastAPI
+
+from stac_fastapi.types.extension import ApiExtension
+
+from .request import CollectionSearchExtensionGetRequest
+
+
+class ConformanceClasses(str, Enum):
+    """Conformance classes for the Collection-Search extension.
+
+    See
+    https://github.com/stac-api-extensions/collection-search
+    """
+
+    COLLECTIONSEARCH = "https://api.stacspec.org/v1.0.0-rc.1/collection-search"
+    BASIS = "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query"
+    FREETEXT = "https://api.stacspec.org/v1.0.0-rc.1/collection-search#free-text"
+    FILTER = "https://api.stacspec.org/v1.0.0-rc.1/collection-search#filter"
+    QUERY = "https://api.stacspec.org/v1.0.0-rc.1/collection-search#query"
+    SORT = "https://api.stacspec.org/v1.0.0-rc.1/collection-search#sort"
+    FIELDS = "https://api.stacspec.org/v1.0.0-rc.1/collection-search#fields"
+
+
+@attr.s
+class CollectionSearchExtension(ApiExtension):
+    """Collection-Search Extension.
+
+    The Collection-Search extension adds functionality to the `GET - /collections`
+    endpoint which allows the caller to include or exclude specific from the API
+    response.
+    Registering this extension with the application has the added effect of
+    removing the `ItemCollection` response model from the `/search` endpoint, as
+    the Fields extension allows the API to return potentially invalid responses
+    by excluding fields which are required by the STAC spec, such as geometry.
+
+    https://github.com/stac-api-extensions/collection-search
+
+    Attributes:
+        conformance_classes (list): Defines the list of conformance classes for
+            the extension
+    """
+
+    GET = CollectionSearchExtensionGetRequest
+    POST = None
+
+    conformance_classes: List[str] = attr.ib(
+        default=[ConformanceClasses.COLLECTIONSEARCH, ConformanceClasses.BASIS]
+    )
+    schema_href: Optional[str] = attr.ib(default=None)
+
+    def register(self, app: FastAPI) -> None:
+        """Register the extension with a FastAPI application.
+
+        Args:
+            app (fastapi.FastAPI): target FastAPI application.
+
+        Returns:
+            None
+        """
+        pass
diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py
new file mode 100644
index 0000000..663f488
--- /dev/null
+++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py
@@ -0,0 +1,27 @@
+"""Request models for the Collection-Search extension."""
+
+from typing import Optional
+
+import attr
+from fastapi import Query
+from stac_pydantic.shared import BBox
+from typing_extensions import Annotated
+
+from stac_fastapi.types.rfc3339 import DateTimeType
+from stac_fastapi.types.search import APIRequest, _bbox_converter, _datetime_converter
+
+
+@attr.s
+class CollectionSearchExtensionGetRequest(APIRequest):
+    """Basics additional Collection-Search parameters for the GET request."""
+
+    bbox: Optional[BBox] = attr.ib(default=None, converter=_bbox_converter)
+    datetime: Optional[DateTimeType] = attr.ib(
+        default=None, converter=_datetime_converter
+    )
+    limit: Annotated[
+        Optional[int],
+        Query(
+            description="Limits the number of results that are included in each page of the response."  # noqa: E501
+        ),
+    ] = attr.ib(default=10)


Test patch:
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 0000000..856c5b0
--- /dev/null
+++ b/stac_fastapi/extensions/tests/test_collection_search.py
@@ -0,0 +1,181 @@
+import json
+from urllib.parse import quote_plus
+
+from starlette.testclient import TestClient
+
+from stac_fastapi.api.app import StacApi
+from stac_fastapi.api.models import create_request_model
+from stac_fastapi.extensions.core import CollectionSearchExtension
+from stac_fastapi.extensions.core.collection_search import ConformanceClasses
+from stac_fastapi.extensions.core.collection_search.request import (
+    CollectionSearchExtensionGetRequest,
+)
+from stac_fastapi.extensions.core.fields.request import FieldsExtensionGetRequest
+from stac_fastapi.extensions.core.filter.request import FilterExtensionGetRequest
+from stac_fastapi.extensions.core.free_text.request import FreeTextExtensionGetRequest
+from stac_fastapi.extensions.core.query.request import QueryExtensionGetRequest
+from stac_fastapi.extensions.core.sort.request import SortExtensionGetRequest
+from stac_fastapi.types.config import ApiSettings
+from stac_fastapi.types.core import BaseCoreClient
+
+
+class DummyCoreClient(BaseCoreClient):
+    def all_collections(self, *args, **kwargs):
+        _ = kwargs.pop("request", None)
+        return kwargs
+
+    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):
+        return args[0].model_dump()
+
+    def item_collection(self, *args, **kwargs):
+        raise NotImplementedError
+
+
+def test_collection_search_extension_default():
+    """Test /collections endpoint with collection-search ext."""
+    api = StacApi(
+        settings=ApiSettings(),
+        client=DummyCoreClient(),
+        extensions=[CollectionSearchExtension()],
+        collections_get_request_model=CollectionSearchExtensionGetRequest,
+    )
+    with TestClient(api.app) as client:
+        response = client.get("/conformance")
+        assert response.is_success, response.json()
+        response_dict = response.json()
+        assert (
+            "https://api.stacspec.org/v1.0.0-rc.1/collection-search"
+            in response_dict["conformsTo"]
+        )
+        assert (
+            "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query"
+            in response_dict["conformsTo"]
+        )
+
+        response = client.get("/collections")
+        assert response.is_success, response.json()
+        response_dict = response.json()
+        assert "bbox" in response_dict
+        assert "datetime" in response_dict
+        assert "limit" in response_dict
+
+        response = client.get(
+            "/collections",
+            params={
+                "datetime": "2020-06-13T13:00:00Z/2020-06-13T14:00:00Z",
+                "bbox": "-175.05,-85.05,175.05,85.05",
+                "limit": 100,
+            },
+        )
+        assert response.is_success, response.json()
+        response_dict = response.json()
+        assert [-175.05, -85.05, 175.05, 85.05] == response_dict["bbox"]
+        assert [
+            "2020-06-13T13:00:00+00:00",
+            "2020-06-13T14:00:00+00:00",
+        ] == response_dict["datetime"]
+        assert 100 == response_dict["limit"]
+
+
+def test_collection_search_extension_models():
+    """Test /collections endpoint with collection-search ext with additional models."""
+    collections_get_request_model = create_request_model(
+        model_name="SearchGetRequest",
+        base_model=CollectionSearchExtensionGetRequest,
+        mixins=[
+            FreeTextExtensionGetRequest,
+            FilterExtensionGetRequest,
+            QueryExtensionGetRequest,
+            SortExtensionGetRequest,
+            FieldsExtensionGetRequest,
+        ],
+        request_type="GET",
+    )
+
+    api = StacApi(
+        settings=ApiSettings(),
+        client=DummyCoreClient(),
+        extensions=[
+            CollectionSearchExtension(
+                conformance_classes=[
+                    ConformanceClasses.COLLECTIONSEARCH,
+                    ConformanceClasses.BASIS,
+                    ConformanceClasses.FREETEXT,
+                    ConformanceClasses.FILTER,
+                    ConformanceClasses.QUERY,
+                    ConformanceClasses.SORT,
+                    ConformanceClasses.FIELDS,
+                ]
+            )
+        ],
+        collections_get_request_model=collections_get_request_model,
+    )
+    with TestClient(api.app) as client:
+        response = client.get("/conformance")
+        assert response.is_success, response.json()
+        response_dict = response.json()
+        conforms = response_dict["conformsTo"]
+        assert "https://api.stacspec.org/v1.0.0-rc.1/collection-search" in conforms
+        assert (
+            "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query"
+            in conforms
+        )
+        assert (
+            "https://api.stacspec.org/v1.0.0-rc.1/collection-search#free-text" in conforms
+        )
+        assert "https://api.stacspec.org/v1.0.0-rc.1/collection-search#filter" in conforms
+        assert "https://api.stacspec.org/v1.0.0-rc.1/collection-search#query" in conforms
+        assert "https://api.stacspec.org/v1.0.0-rc.1/collection-search#sort" in conforms
+        assert "https://api.stacspec.org/v1.0.0-rc.1/collection-search#fields" in conforms
+
+        response = client.get("/collections")
+        assert response.is_success, response.json()
+        response_dict = response.json()
+        assert "bbox" in response_dict
+        assert "datetime" in response_dict
+        assert "limit" in response_dict
+        assert "q" in response_dict
+        assert "filter" in response_dict
+        assert "query" in response_dict
+        assert "sortby" in response_dict
+        assert "fields" in response_dict
+
+        response = client.get(
+            "/collections",
+            params={
+                "datetime": "2020-06-13T13:00:00Z/2020-06-13T14:00:00Z",
+                "bbox": "-175.05,-85.05,175.05,85.05",
+                "limit": 100,
+                "q": "EO,Earth Observation",
+                "filter": "id='item_id' AND collection='collection_id'",
+                "query": quote_plus(
+                    json.dumps({"eo:cloud_cover": {"gte": 95}}),
+                ),
+                "sortby": "-gsd,-datetime",
+                "fields": "properties.datetime",
+            },
+        )
+        assert response.is_success, response.json()
+        response_dict = response.json()
+        assert [-175.05, -85.05, 175.05, 85.05] == response_dict["bbox"]
+        assert [
+            "2020-06-13T13:00:00+00:00",
+            "2020-06-13T14:00:00+00:00",
+        ] == response_dict["datetime"]
+        assert 100 == response_dict["limit"]
+        assert ["EO", "Earth Observation"] == response_dict["q"]
+        assert "id='item_id' AND collection='collection_id'" == response_dict["filter"]
+        assert "filter_crs" in response_dict
+        assert "cql2-text" in response_dict["filter_lang"]
+        assert "query" in response_dict
+        assert ["-gsd", "-datetime"] == response_dict["sortby"]
+        assert ["properties.datetime"] == response_dict["fields"]