diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index 56afcbc8..37fcd317 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -1,5 +1,5 @@ """Core client.""" - +import json import logging from datetime import datetime as datetime_type from datetime import timezone @@ -21,16 +21,21 @@ from stac_fastapi.core.base_database_logic import BaseDatabaseLogic from stac_fastapi.core.base_settings import ApiBaseSettings +from stac_fastapi.core.models import CollectionSearchPostRequest from stac_fastapi.core.models.links import PagingLinks from stac_fastapi.core.serializers import CollectionSerializer, ItemSerializer from stac_fastapi.core.session import Session from stac_fastapi.core.utilities import filter_fields +from stac_fastapi.extensions.core.collection_search.collection_search import ( + AsyncBaseCollectionSearchClient, +) from stac_fastapi.extensions.core.filter.client import AsyncBaseFiltersClient from stac_fastapi.extensions.third_party.bulk_transactions import ( BaseBulkTransactionsClient, BulkTransactionMethod, Items, ) +from stac_fastapi.types import stac from stac_fastapi.types import stac as stac_types from stac_fastapi.types.conformance import BASE_CONFORMANCE_CLASSES from stac_fastapi.types.core import AsyncBaseCoreClient, AsyncBaseTransactionsClient @@ -553,7 +558,8 @@ async def post_search( """ base_url = str(request.base_url) - search = self.database.make_search() + is_collection_search = isinstance(search_request, CollectionSearchPostRequest) + search = self.database.make_search(is_collection_search=is_collection_search) if search_request.ids: search = self.database.apply_ids_filter( @@ -594,8 +600,11 @@ async def post_search( ) # only cql2_json is supported here - if hasattr(search_request, "filter"): - cql2_filter = getattr(search_request, "filter", None) + if filter := search_request.filter: + cql2_filter = filter + if search_request.filter_lang == "cql2-text": + cql2_filter = json.loads(to_cql2(parse_cql2_text(cql2_filter))) + # only cql2_json is supported here try: search = self.database.apply_cql2_filter(search, cql2_filter) except Exception as e: @@ -603,8 +612,8 @@ async def post_search( status_code=400, detail=f"Error with cql2_json filter: {e}" ) - if hasattr(search_request, "q"): - free_text_queries = getattr(search_request, "q", None) + if q := search_request.q: + free_text_queries = q try: search = self.database.apply_free_text_filter(search, free_text_queries) except Exception as e: @@ -636,9 +645,21 @@ async def post_search( include: Set[str] = fields.include if fields and fields.include else set() exclude: Set[str] = fields.exclude if fields and fields.exclude else set() + if is_collection_search: + + def serializer(self, item): + return self.collection_serializer.db_to_stac( + collection=item, request=request, extensions=self.extensions + ) + + else: + + def serializer(self, item): + return self.item_serializer.db_to_stac(item, base_url=base_url) + items = [ filter_fields( - self.item_serializer.db_to_stac(item, base_url=base_url), + serializer(self, item), include, exclude, ) @@ -646,13 +667,22 @@ async def post_search( ] links = await PagingLinks(request=request, next=next_token).get_links() - return stac_types.ItemCollection( - type="FeatureCollection", - features=items, - links=links, - numReturned=len(items), - numMatched=maybe_count, - ) + if is_collection_search: + return stac_types.ItemCollection( + type="FeatureCollection", + collections=items, + links=links, + numReturned=len(items), + numMatched=maybe_count, + ) + else: + return stac_types.ItemCollection( + type="FeatureCollection", + features=items, + links=links, + numReturned=len(items), + numMatched=maybe_count, + ) @attr.s @@ -984,3 +1014,32 @@ async def get_queryables( }, "additionalProperties": True, } + + +@attr.s +class AsyncCollectionSearchClient(AsyncBaseCollectionSearchClient, CoreClient): + """AsyncCollectionSearchClient class.""" + + def __init__(self, database_logic, **kwargs): + """Run the Constructor.""" + super(AsyncCollectionSearchClient, self).__init__(**kwargs) + self.database = database_logic + + async def post_all_collections( + self, search_request: CollectionSearchPostRequest, **kwargs + ) -> stac.ItemCollection: + """ + Perform a POST search on the catalog. + + Args: + search_request (BaseSearchPostRequest): Request object that includes the parameters for the search. + kwargs: Keyword arguments passed to the function. + + Returns: + ItemCollection: A collection of items matching the search criteria. + + Raises: + HTTPException: If there is an error with the cql2_json filter. + """ + request = kwargs.get("request") + return await self.post_search(search_request=search_request, request=request) diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/__init__.py b/stac_fastapi/core/stac_fastapi/core/extensions/__init__.py index 7ee6eea5..203048e9 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/__init__.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/__init__.py @@ -1,5 +1,11 @@ """elasticsearch extensions modifications.""" +from .collection_post_search import CollectionSearchPostExtension from .query import Operator, QueryableTypes, QueryExtension -__all__ = ["Operator", "QueryableTypes", "QueryExtension"] +__all__ = [ + "Operator", + "QueryableTypes", + "QueryExtension", + "CollectionSearchPostExtension", +] diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/collection_post_search.py b/stac_fastapi/core/stac_fastapi/core/extensions/collection_post_search.py new file mode 100644 index 00000000..9a71e956 --- /dev/null +++ b/stac_fastapi/core/stac_fastapi/core/extensions/collection_post_search.py @@ -0,0 +1,90 @@ +"""Request model for the Aggregation extension.""" + +from typing import List, Optional, Union + +import attr +from fastapi import APIRouter, FastAPI +from stac_pydantic.api.collections import Collections +from stac_pydantic.shared import MimeTypes + +from stac_fastapi.api.models import GeoJSONResponse +from stac_fastapi.api.routes import create_async_endpoint +from stac_fastapi.extensions.core.collection_search import ( + CollectionSearchExtension, + ConformanceClasses, +) +from stac_fastapi.extensions.core.collection_search.client import ( + AsyncBaseCollectionSearchClient, + BaseCollectionSearchClient, +) +from stac_fastapi.extensions.core.collection_search.request import ( + BaseCollectionSearchGetRequest, + BaseCollectionSearchPostRequest, +) +from stac_fastapi.types.config import ApiSettings + + +@attr.s +class CollectionSearchPostExtension(CollectionSearchExtension): + """Collection-Search Extension. + + Extents the collection-search extension with an additional + POST - /collections endpoint + + NOTE: the POST - /collections endpoint can be conflicting with the + POST /collections endpoint registered for the Transaction extension. + + https://github.com/stac-api-extensions/collection-search + + Attributes: + conformance_classes (list): Defines the list of conformance classes for + the extension + """ + + client: Union[ + AsyncBaseCollectionSearchClient, BaseCollectionSearchClient + ] = attr.ib() + settings: ApiSettings = attr.ib() + conformance_classes: List[str] = attr.ib( + default=[ConformanceClasses.COLLECTIONSEARCH, ConformanceClasses.BASIS] + ) + schema_href: Optional[str] = attr.ib(default=None) + router: APIRouter = attr.ib(factory=APIRouter) + + GET: BaseCollectionSearchGetRequest = attr.ib( + default=BaseCollectionSearchGetRequest + ) + POST: BaseCollectionSearchPostRequest = attr.ib( + default=BaseCollectionSearchPostRequest + ) + + 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="Collections searcb", + path="/collections-search", + methods=["POST"], + response_model=( + Collections if self.settings.enable_response_models else None + ), + responses={ + 200: { + "content": { + MimeTypes.json.value: {}, + }, + "model": Collections, + }, + }, + response_class=GeoJSONResponse, + endpoint=create_async_endpoint(self.client.post_all_collections, self.POST), + ) + app.include_router(self.router) diff --git a/stac_fastapi/core/stac_fastapi/core/models/__init__.py b/stac_fastapi/core/stac_fastapi/core/models/__init__.py index d0748bcc..12bacab7 100644 --- a/stac_fastapi/core/stac_fastapi/core/models/__init__.py +++ b/stac_fastapi/core/stac_fastapi/core/models/__init__.py @@ -1 +1,5 @@ """stac_fastapi.elasticsearch.models module.""" +from .search import CollectionSearchPostRequest + +"""elasticsearch extensions modifications.""" +__all__ = ["CollectionSearchPostRequest"] diff --git a/stac_fastapi/core/stac_fastapi/core/models/search.py b/stac_fastapi/core/stac_fastapi/core/models/search.py index 33b73b68..8d55ab57 100644 --- a/stac_fastapi/core/stac_fastapi/core/models/search.py +++ b/stac_fastapi/core/stac_fastapi/core/models/search.py @@ -1 +1,35 @@ -"""Unused search model.""" +"""Search model.""" + +from typing import List, Optional + +from stac_fastapi.extensions.core.collection_search.collection_search import ( + BaseCollectionSearchPostRequest, +) +from stac_fastapi.types.search import BaseSearchPostRequest + + +# CollectionSearchPostRequest model. +class CollectionSearchPostRequest( + BaseCollectionSearchPostRequest, BaseSearchPostRequest +): + """The CollectionSearchPostRequest class.""" + + query: Optional[str] = None + token: Optional[str] = None + fields: Optional[List[str]] = None + sortby: Optional[str] = None + intersects: Optional[str] = None + filter: Optional[str] = None + filter_lang: Optional[str] = None + q: Optional[str] = None + + def __init__(self, **kwargs): + """Run the Constructor.""" + super().__init__(**kwargs) + self.query = kwargs.get("query", None) + self.token = kwargs.get("token", None) + self.sortby = kwargs.get("sortby", None) + self.fields = kwargs.get("fields", None) + self.filter = kwargs.get("filter", None) + self.filter_lang = kwargs.get("filter-lang", None) + self.q = kwargs.get("q", None) diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py index 6b26c2ac..a66fcec4 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py @@ -5,18 +5,20 @@ from stac_fastapi.api.app import StacApi from stac_fastapi.api.models import create_get_request_model, create_post_request_model from stac_fastapi.core.core import ( + AsyncCollectionSearchClient, BulkTransactionsClient, CoreClient, EsAsyncBaseFiltersClient, TransactionsClient, ) -from stac_fastapi.core.extensions import QueryExtension +from stac_fastapi.core.extensions import CollectionSearchPostExtension, QueryExtension from stac_fastapi.core.extensions.aggregation import ( EsAggregationExtensionGetRequest, EsAggregationExtensionPostRequest, EsAsyncAggregationClient, ) from stac_fastapi.core.extensions.fields import FieldsExtension +from stac_fastapi.core.models import CollectionSearchPostRequest from stac_fastapi.core.route_dependencies import get_route_dependencies from stac_fastapi.core.session import Session from stac_fastapi.elasticsearch.config import ElasticsearchSettings @@ -25,7 +27,7 @@ create_collection_index, create_index_templates, ) -from stac_fastapi.extensions.core import ( +from stac_fastapi.extensions.core import ( # CollectionSearchExtension,; CollectionSearchPostExtension AggregationExtension, FilterExtension, FreeTextExtension, @@ -53,7 +55,11 @@ aggregation_extension.POST = EsAggregationExtensionPostRequest aggregation_extension.GET = EsAggregationExtensionGetRequest +collection_client = AsyncCollectionSearchClient(database=database_logic) search_extensions = [ + CollectionSearchPostExtension( + POST=CollectionSearchPostRequest, client=collection_client, settings=settings + ), TransactionExtension( client=TransactionsClient( database=database_logic, session=session, settings=settings diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index da6d6880..1b18e3c5 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -1,5 +1,4 @@ """Database logic.""" - import asyncio import logging import os @@ -303,6 +302,16 @@ class Geometry(Protocol): # noqa coordinates: Any +# A marker class to distinguish the type of search + + +class CollectionSearch(Search): + """CollectionSearch marker class.""" + + # def __init__(self, kwargs=None): + # super().__init__(kwargs **kwargs) + + @attr.s class DatabaseLogic: """Database logic.""" @@ -458,8 +467,16 @@ async def get_one_item(self, collection_id: str, item_id: str) -> Dict: return item["_source"] @staticmethod - def make_search(): + def make_search(is_collection_search=False): """Database logic to create a Search instance.""" + if is_collection_search: + return CollectionSearch().sort( + *{ + "datetime": {"order": "desc"}, + "id": {"order": "desc"}, + "collection": {"order": "desc"}, + } + ) return Search().sort(*DEFAULT_SORT) @staticmethod @@ -582,17 +599,6 @@ def apply_stacql_filter(search: Search, op: str, field: str, value: float): return search - @staticmethod - def apply_free_text_filter(search: Search, free_text_queries: Optional[List[str]]): - """Database logic to perform query for search endpoint.""" - if free_text_queries is not None: - free_text_query_string = '" OR properties.\\*:"'.join(free_text_queries) - search = search.query( - "query_string", query=f'properties.\\*:"{free_text_query_string}"' - ) - - return search - @staticmethod def apply_cql2_filter(search: Search, _filter: Optional[Dict[str, Any]]): """ @@ -664,7 +670,10 @@ async def execute_search( query = search.query.to_dict() if search.query else None - index_param = indices(collection_ids) + if is_collection_search := isinstance(search, CollectionSearch): + index_param = f"{COLLECTIONS_INDEX}-000001" + else: + index_param = indices(collection_ids) max_result_window = MAX_LIMIT @@ -675,7 +684,7 @@ async def execute_search( index=index_param, ignore_unavailable=ignore_unavailable, query=query, - sort=sort or DEFAULT_SORT, + sort=sort or None, search_after=search_after, size=size_limit, ) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py index 778cfe03..c76d0b53 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py @@ -485,7 +485,7 @@ async def get_one_item(self, collection_id: str, item_id: str) -> Dict: return item["_source"] @staticmethod - def make_search(): + def make_search(is_collection_search=False): """Database logic to create a Search instance.""" return Search().sort(*DEFAULT_SORT)