Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## [Unreleased]

### Added

- GET `/collections` collection search sort extension ex. `/collections?sortby=+id`. [#456](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/456)

### Changed

- Fixed a bug where missing `copy()` caused default queryables to be incorrectly enriched by results from previous queries. [#427](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/427)
Expand Down
22 changes: 18 additions & 4 deletions stac_fastapi/core/stac_fastapi/core/base_database_logic.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Base database logic."""

import abc
from typing import Any, Dict, Iterable, List, Optional
from typing import Any, Dict, Iterable, List, Optional, Tuple


class BaseDatabaseLogic(abc.ABC):
Expand All @@ -14,9 +14,23 @@ class BaseDatabaseLogic(abc.ABC):

@abc.abstractmethod
async def get_all_collections(
self, token: Optional[str], limit: int
) -> Iterable[Dict[str, Any]]:
"""Retrieve a list of all collections from the database."""
self,
token: Optional[str],
limit: int,
request: Any = None,
sort: Optional[List[Dict[str, Any]]] = None,
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
"""Retrieve a list of collections from the database, supporting pagination.

Args:
token (Optional[str]): The pagination token.
limit (int): The number of results to return.
request (Any, optional): The FastAPI request object. Defaults to None.
sort (Optional[List[Dict[str, Any]]], optional): Optional sort parameter. Defaults to None.

Returns:
A tuple of (collections, next pagination token if any).
"""
pass

@abc.abstractmethod
Expand Down
21 changes: 19 additions & 2 deletions stac_fastapi/core/stac_fastapi/core/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,9 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage:

return landing_page

async def all_collections(self, **kwargs) -> stac_types.Collections:
async def all_collections(
self, sortby: Optional[str] = None, **kwargs
) -> stac_types.Collections:
"""Read all collections from the database.

Args:
Expand All @@ -238,8 +240,23 @@ async def all_collections(self, **kwargs) -> stac_types.Collections:
limit = int(request.query_params.get("limit", os.getenv("STAC_ITEM_LIMIT", 10)))
token = request.query_params.get("token")

sort = None
if sortby:
parsed_sort = []
for raw in sortby:
if not isinstance(raw, str):
continue
s = raw.strip()
if not s:
continue
direction = "desc" if s[0] == "-" else "asc"
field = s[1:] if s and s[0] in "+-" else s
parsed_sort.append({"field": field, "direction": direction})
if parsed_sort:
sort = parsed_sort

collections, next_token = await self.database.get_all_collections(
token=token, limit=limit, request=request
token=token, limit=limit, request=request, sort=sort
)

links = [
Expand Down
34 changes: 24 additions & 10 deletions stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
create_collection_index,
create_index_templates,
)
from stac_fastapi.extensions.core import (
from stac_fastapi.extensions.core import ( # CollectionSearchFilterExtension,
AggregationExtension,
CollectionSearchExtension,
FilterExtension,
Expand All @@ -45,6 +45,8 @@
)
from stac_fastapi.extensions.core.fields import FieldsConformanceClasses
from stac_fastapi.extensions.core.filter import FilterConformanceClasses

# from stac_fastapi.extensions.core.free_text import FreeTextConformanceClasses
from stac_fastapi.extensions.core.query import QueryConformanceClasses
from stac_fastapi.extensions.core.sort import SortConformanceClasses
from stac_fastapi.extensions.third_party import BulkTransactionExtension
Expand All @@ -70,14 +72,6 @@
FilterConformanceClasses.ADVANCED_COMPARISON_OPERATORS
)

# Adding collection search extension for compatibility with stac-auth-proxy
# (https://github.com/developmentseed/stac-auth-proxy)
# The extension is not fully implemented yet but is required for collection filtering support
collection_search_extension = CollectionSearchExtension()
collection_search_extension.conformance_classes.append(
"https://api.stacspec.org/v1.0.0-rc.1/collection-search#filter"
)

aggregation_extension = AggregationExtension(
client=EsAsyncBaseAggregationClient(
database=database_logic, session=session, settings=settings
Expand All @@ -96,7 +90,6 @@
TokenPaginationExtension(),
filter_extension,
FreeTextExtension(),
collection_search_extension,
]

if TRANSACTIONS_EXTENSIONS:
Expand All @@ -122,6 +115,26 @@

extensions = [aggregation_extension] + search_extensions

# Create collection search extensions
# Only sort extension is enabled for now
collection_search_extensions = [
# QueryExtension(conformance_classes=[QueryConformanceClasses.COLLECTIONS]),
SortExtension(conformance_classes=[SortConformanceClasses.COLLECTIONS]),
# FieldsExtension(conformance_classes=[FieldsConformanceClasses.COLLECTIONS]),
# CollectionSearchFilterExtension(
# conformance_classes=[FilterConformanceClasses.COLLECTIONS]
# ),
# FreeTextExtension(conformance_classes=[FreeTextConformanceClasses.COLLECTIONS]),
]

# Initialize collection search with its extensions
collection_search_ext = CollectionSearchExtension.from_extensions(
collection_search_extensions
)
collections_get_request_model = collection_search_ext.GET

extensions.append(collection_search_ext)

database_logic.extensions = [type(ext).__name__ for ext in extensions]

post_request_model = create_post_request_model(search_extensions)
Expand Down Expand Up @@ -157,6 +170,7 @@
"search_get_request_model": create_get_request_model(search_extensions),
"search_post_request_model": post_request_model,
"items_get_request_model": items_get_request_model,
"collections_get_request_model": collections_get_request_model,
"route_dependencies": get_route_dependencies(),
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,28 +170,47 @@ def __attrs_post_init__(self):
"""CORE LOGIC"""

async def get_all_collections(
self, token: Optional[str], limit: int, request: Request
self,
token: Optional[str],
limit: int,
request: Request,
sort: Optional[List[Dict[str, Any]]] = None,
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
"""Retrieve a list of all collections from Elasticsearch, supporting pagination.
"""Retrieve a list of collections from Elasticsearch, supporting pagination.

Args:
token (Optional[str]): The pagination token.
limit (int): The number of results to return.
request (Request): The FastAPI request object.
sort (Optional[List[Dict[str, Any]]]): Optional sort parameter from the request.

Returns:
A tuple of (collections, next pagination token if any).
"""
search_after = None
formatted_sort = []
if sort:
for item in sort:
field = item.get("field")
direction = item.get("direction", "asc")
if field:
formatted_sort.append({field: {"order": direction}})
# Always include id as a secondary sort to ensure consistent pagination
if not any("id" in item for item in formatted_sort):
formatted_sort.append({"id": {"order": "asc"}})
else:
formatted_sort = [{"id": {"order": "asc"}}]

body = {
"sort": formatted_sort,
"size": limit,
}

if token:
search_after = [token]
body["search_after"] = [token]

response = await self.client.search(
index=COLLECTIONS_INDEX,
body={
"sort": [{"id": {"order": "asc"}}],
"size": limit,
**({"search_after": search_after} if search_after is not None else {}),
},
body=body,
)

hits = response["hits"]["hits"]
Expand All @@ -204,7 +223,9 @@ async def get_all_collections(

next_token = None
if len(hits) == limit:
next_token = hits[-1]["sort"][0]
next_token_values = hits[-1].get("sort")
if next_token_values:
next_token = next_token_values[0]

return collections, next_token

Expand Down
34 changes: 24 additions & 10 deletions stac_fastapi/opensearch/stac_fastapi/opensearch/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from stac_fastapi.core.route_dependencies import get_route_dependencies
from stac_fastapi.core.session import Session
from stac_fastapi.core.utilities import get_bool_env
from stac_fastapi.extensions.core import (
from stac_fastapi.extensions.core import ( # CollectionSearchFilterExtension,
AggregationExtension,
CollectionSearchExtension,
FilterExtension,
Expand All @@ -39,6 +39,8 @@
)
from stac_fastapi.extensions.core.fields import FieldsConformanceClasses
from stac_fastapi.extensions.core.filter import FilterConformanceClasses

# from stac_fastapi.extensions.core.free_text import FreeTextConformanceClasses
from stac_fastapi.extensions.core.query import QueryConformanceClasses
from stac_fastapi.extensions.core.sort import SortConformanceClasses
from stac_fastapi.extensions.third_party import BulkTransactionExtension
Expand Down Expand Up @@ -69,14 +71,6 @@
FilterConformanceClasses.ADVANCED_COMPARISON_OPERATORS
)

# Adding collection search extension for compatibility with stac-auth-proxy
# (https://github.com/developmentseed/stac-auth-proxy)
# The extension is not fully implemented yet but is required for collection filtering support
collection_search_extension = CollectionSearchExtension()
collection_search_extension.conformance_classes.append(
"https://api.stacspec.org/v1.0.0-rc.1/collection-search#filter"
)

aggregation_extension = AggregationExtension(
client=EsAsyncBaseAggregationClient(
database=database_logic, session=session, settings=settings
Expand All @@ -95,7 +89,6 @@
TokenPaginationExtension(),
filter_extension,
FreeTextExtension(),
collection_search_extension,
]


Expand All @@ -122,6 +115,26 @@

extensions = [aggregation_extension] + search_extensions

# Create collection search extensions
# Only sort extension is enabled for now
collection_search_extensions = [
# QueryExtension(conformance_classes=[QueryConformanceClasses.COLLECTIONS]),
SortExtension(conformance_classes=[SortConformanceClasses.COLLECTIONS]),
# FieldsExtension(conformance_classes=[FieldsConformanceClasses.COLLECTIONS]),
# CollectionSearchFilterExtension(
# conformance_classes=[FilterConformanceClasses.COLLECTIONS]
# ),
# FreeTextExtension(conformance_classes=[FreeTextConformanceClasses.COLLECTIONS]),
]

# Initialize collection search with its extensions
collection_search_ext = CollectionSearchExtension.from_extensions(
collection_search_extensions
)
collections_get_request_model = collection_search_ext.GET

extensions.append(collection_search_ext)

database_logic.extensions = [type(ext).__name__ for ext in extensions]

post_request_model = create_post_request_model(search_extensions)
Expand Down Expand Up @@ -154,6 +167,7 @@
post_request_model=post_request_model,
landing_page_id=os.getenv("STAC_FASTAPI_LANDING_PAGE_ID", "stac-fastapi"),
),
"collections_get_request_model": collections_get_request_model,
"search_get_request_model": create_get_request_model(search_extensions),
"search_post_request_model": post_request_model,
"items_get_request_model": items_get_request_model,
Expand Down
34 changes: 25 additions & 9 deletions stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,31 +154,47 @@ def __attrs_post_init__(self):
"""CORE LOGIC"""

async def get_all_collections(
self, token: Optional[str], limit: int, request: Request
self,
token: Optional[str],
limit: int,
request: Request,
sort: Optional[List[Dict[str, Any]]] = None,
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
"""
Retrieve a list of all collections from Opensearch, supporting pagination.
"""Retrieve a list of collections from Elasticsearch, supporting pagination.

Args:
token (Optional[str]): The pagination token.
limit (int): The number of results to return.
request (Request): The FastAPI request object.
sort (Optional[List[Dict[str, Any]]]): Optional sort parameter from the request.

Returns:
A tuple of (collections, next pagination token if any).
"""
search_body = {
"sort": [{"id": {"order": "asc"}}],
formatted_sort = []
if sort:
for item in sort:
field = item.get("field")
direction = item.get("direction", "asc")
if field:
formatted_sort.append({field: {"order": direction}})
# Always include id as a secondary sort to ensure consistent pagination
if not any("id" in item for item in formatted_sort):
formatted_sort.append({"id": {"order": "asc"}})
else:
formatted_sort = [{"id": {"order": "asc"}}]

body = {
"sort": formatted_sort,
"size": limit,
}

# Only add search_after to the query if token is not None and not empty
if token:
search_after = [token]
search_body["search_after"] = search_after
body["search_after"] = [token]

response = await self.client.search(
index=COLLECTIONS_INDEX,
body=search_body,
body=body,
)

hits = response["hits"]["hits"]
Expand Down
Loading