From 4df4947b9822271b0912abe766234edb26a78a59 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Tue, 23 Jul 2024 00:35:11 +0200 Subject: [PATCH] Feature/add collection search extension V2 (#736) * sketch * sketch * fix * set limit to 10 * Update CHANGES.md --- CHANGES.md | 4 +- stac_fastapi/api/stac_fastapi/api/config.py | 1 + .../stac_fastapi/extensions/core/__init__.py | 2 + .../core/collection_search/__init__.py | 5 + .../collection_search/collection_search.py | 66 +++++++ .../core/collection_search/request.py | 27 +++ .../tests/test_collection_search.py | 181 ++++++++++++++++++ 7 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/__init__.py create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/collection_search.py create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py create mode 100644 stac_fastapi/extensions/tests/test_collection_search.py diff --git a/CHANGES.md b/CHANGES.md index 939471bb..fbc15e50 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 74a1c731..275159a0 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 fe8b6646..385bd902 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 @@ "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 00000000..f919491d --- /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 00000000..aac81205 --- /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 00000000..663f488d --- /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) 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 00000000..856c5b03 --- /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"]