Skip to content

Commit 5e41770

Browse files
authored
GET /collections search sort extension (#456)
**Related Issue(s):** - #458 **Description:** Example: /collections?sortby=+id **PR Checklist:** - [x] Code is formatted and linted (run `pre-commit run --all-files`) - [x] Tests pass (run `make test`) - [x] Documentation has been updated to reflect changes, if applicable - [x] Changes are added to the changelog
1 parent b80cf30 commit 5e41770

File tree

8 files changed

+224
-45
lines changed

8 files changed

+224
-45
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
77

88
## [Unreleased]
99

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

1216
- 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)

stac_fastapi/core/stac_fastapi/core/base_database_logic.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Base database logic."""
22

33
import abc
4-
from typing import Any, Dict, Iterable, List, Optional
4+
from typing import Any, Dict, Iterable, List, Optional, Tuple
55

66

77
class BaseDatabaseLogic(abc.ABC):
@@ -14,9 +14,23 @@ class BaseDatabaseLogic(abc.ABC):
1414

1515
@abc.abstractmethod
1616
async def get_all_collections(
17-
self, token: Optional[str], limit: int
18-
) -> Iterable[Dict[str, Any]]:
19-
"""Retrieve a list of all collections from the database."""
17+
self,
18+
token: Optional[str],
19+
limit: int,
20+
request: Any = None,
21+
sort: Optional[List[Dict[str, Any]]] = None,
22+
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
23+
"""Retrieve a list of collections from the database, supporting pagination.
24+
25+
Args:
26+
token (Optional[str]): The pagination token.
27+
limit (int): The number of results to return.
28+
request (Any, optional): The FastAPI request object. Defaults to None.
29+
sort (Optional[List[Dict[str, Any]]], optional): Optional sort parameter. Defaults to None.
30+
31+
Returns:
32+
A tuple of (collections, next pagination token if any).
33+
"""
2034
pass
2135

2236
@abc.abstractmethod

stac_fastapi/core/stac_fastapi/core/core.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,9 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage:
224224

225225
return landing_page
226226

227-
async def all_collections(self, **kwargs) -> stac_types.Collections:
227+
async def all_collections(
228+
self, sortby: Optional[str] = None, **kwargs
229+
) -> stac_types.Collections:
228230
"""Read all collections from the database.
229231
230232
Args:
@@ -238,8 +240,23 @@ async def all_collections(self, **kwargs) -> stac_types.Collections:
238240
limit = int(request.query_params.get("limit", os.getenv("STAC_ITEM_LIMIT", 10)))
239241
token = request.query_params.get("token")
240242

243+
sort = None
244+
if sortby:
245+
parsed_sort = []
246+
for raw in sortby:
247+
if not isinstance(raw, str):
248+
continue
249+
s = raw.strip()
250+
if not s:
251+
continue
252+
direction = "desc" if s[0] == "-" else "asc"
253+
field = s[1:] if s and s[0] in "+-" else s
254+
parsed_sort.append({"field": field, "direction": direction})
255+
if parsed_sort:
256+
sort = parsed_sort
257+
241258
collections, next_token = await self.database.get_all_collections(
242-
token=token, limit=limit, request=request
259+
token=token, limit=limit, request=request, sort=sort
243260
)
244261

245262
links = [

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
create_collection_index,
3535
create_index_templates,
3636
)
37-
from stac_fastapi.extensions.core import (
37+
from stac_fastapi.extensions.core import ( # CollectionSearchFilterExtension,
3838
AggregationExtension,
3939
CollectionSearchExtension,
4040
FilterExtension,
@@ -45,6 +45,8 @@
4545
)
4646
from stac_fastapi.extensions.core.fields import FieldsConformanceClasses
4747
from stac_fastapi.extensions.core.filter import FilterConformanceClasses
48+
49+
# from stac_fastapi.extensions.core.free_text import FreeTextConformanceClasses
4850
from stac_fastapi.extensions.core.query import QueryConformanceClasses
4951
from stac_fastapi.extensions.core.sort import SortConformanceClasses
5052
from stac_fastapi.extensions.third_party import BulkTransactionExtension
@@ -70,14 +72,6 @@
7072
FilterConformanceClasses.ADVANCED_COMPARISON_OPERATORS
7173
)
7274

73-
# Adding collection search extension for compatibility with stac-auth-proxy
74-
# (https://github.com/developmentseed/stac-auth-proxy)
75-
# The extension is not fully implemented yet but is required for collection filtering support
76-
collection_search_extension = CollectionSearchExtension()
77-
collection_search_extension.conformance_classes.append(
78-
"https://api.stacspec.org/v1.0.0-rc.1/collection-search#filter"
79-
)
80-
8175
aggregation_extension = AggregationExtension(
8276
client=EsAsyncBaseAggregationClient(
8377
database=database_logic, session=session, settings=settings
@@ -96,7 +90,6 @@
9690
TokenPaginationExtension(),
9791
filter_extension,
9892
FreeTextExtension(),
99-
collection_search_extension,
10093
]
10194

10295
if TRANSACTIONS_EXTENSIONS:
@@ -122,6 +115,26 @@
122115

123116
extensions = [aggregation_extension] + search_extensions
124117

118+
# Create collection search extensions
119+
# Only sort extension is enabled for now
120+
collection_search_extensions = [
121+
# QueryExtension(conformance_classes=[QueryConformanceClasses.COLLECTIONS]),
122+
SortExtension(conformance_classes=[SortConformanceClasses.COLLECTIONS]),
123+
# FieldsExtension(conformance_classes=[FieldsConformanceClasses.COLLECTIONS]),
124+
# CollectionSearchFilterExtension(
125+
# conformance_classes=[FilterConformanceClasses.COLLECTIONS]
126+
# ),
127+
# FreeTextExtension(conformance_classes=[FreeTextConformanceClasses.COLLECTIONS]),
128+
]
129+
130+
# Initialize collection search with its extensions
131+
collection_search_ext = CollectionSearchExtension.from_extensions(
132+
collection_search_extensions
133+
)
134+
collections_get_request_model = collection_search_ext.GET
135+
136+
extensions.append(collection_search_ext)
137+
125138
database_logic.extensions = [type(ext).__name__ for ext in extensions]
126139

127140
post_request_model = create_post_request_model(search_extensions)
@@ -157,6 +170,7 @@
157170
"search_get_request_model": create_get_request_model(search_extensions),
158171
"search_post_request_model": post_request_model,
159172
"items_get_request_model": items_get_request_model,
173+
"collections_get_request_model": collections_get_request_model,
160174
"route_dependencies": get_route_dependencies(),
161175
}
162176

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -170,28 +170,47 @@ def __attrs_post_init__(self):
170170
"""CORE LOGIC"""
171171

172172
async def get_all_collections(
173-
self, token: Optional[str], limit: int, request: Request
173+
self,
174+
token: Optional[str],
175+
limit: int,
176+
request: Request,
177+
sort: Optional[List[Dict[str, Any]]] = None,
174178
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
175-
"""Retrieve a list of all collections from Elasticsearch, supporting pagination.
179+
"""Retrieve a list of collections from Elasticsearch, supporting pagination.
176180
177181
Args:
178182
token (Optional[str]): The pagination token.
179183
limit (int): The number of results to return.
184+
request (Request): The FastAPI request object.
185+
sort (Optional[List[Dict[str, Any]]]): Optional sort parameter from the request.
180186
181187
Returns:
182188
A tuple of (collections, next pagination token if any).
183189
"""
184-
search_after = None
190+
formatted_sort = []
191+
if sort:
192+
for item in sort:
193+
field = item.get("field")
194+
direction = item.get("direction", "asc")
195+
if field:
196+
formatted_sort.append({field: {"order": direction}})
197+
# Always include id as a secondary sort to ensure consistent pagination
198+
if not any("id" in item for item in formatted_sort):
199+
formatted_sort.append({"id": {"order": "asc"}})
200+
else:
201+
formatted_sort = [{"id": {"order": "asc"}}]
202+
203+
body = {
204+
"sort": formatted_sort,
205+
"size": limit,
206+
}
207+
185208
if token:
186-
search_after = [token]
209+
body["search_after"] = [token]
187210

188211
response = await self.client.search(
189212
index=COLLECTIONS_INDEX,
190-
body={
191-
"sort": [{"id": {"order": "asc"}}],
192-
"size": limit,
193-
**({"search_after": search_after} if search_after is not None else {}),
194-
},
213+
body=body,
195214
)
196215

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

205224
next_token = None
206225
if len(hits) == limit:
207-
next_token = hits[-1]["sort"][0]
226+
next_token_values = hits[-1].get("sort")
227+
if next_token_values:
228+
next_token = next_token_values[0]
208229

209230
return collections, next_token
210231

stac_fastapi/opensearch/stac_fastapi/opensearch/app.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from stac_fastapi.core.route_dependencies import get_route_dependencies
2929
from stac_fastapi.core.session import Session
3030
from stac_fastapi.core.utilities import get_bool_env
31-
from stac_fastapi.extensions.core import (
31+
from stac_fastapi.extensions.core import ( # CollectionSearchFilterExtension,
3232
AggregationExtension,
3333
CollectionSearchExtension,
3434
FilterExtension,
@@ -39,6 +39,8 @@
3939
)
4040
from stac_fastapi.extensions.core.fields import FieldsConformanceClasses
4141
from stac_fastapi.extensions.core.filter import FilterConformanceClasses
42+
43+
# from stac_fastapi.extensions.core.free_text import FreeTextConformanceClasses
4244
from stac_fastapi.extensions.core.query import QueryConformanceClasses
4345
from stac_fastapi.extensions.core.sort import SortConformanceClasses
4446
from stac_fastapi.extensions.third_party import BulkTransactionExtension
@@ -69,14 +71,6 @@
6971
FilterConformanceClasses.ADVANCED_COMPARISON_OPERATORS
7072
)
7173

72-
# Adding collection search extension for compatibility with stac-auth-proxy
73-
# (https://github.com/developmentseed/stac-auth-proxy)
74-
# The extension is not fully implemented yet but is required for collection filtering support
75-
collection_search_extension = CollectionSearchExtension()
76-
collection_search_extension.conformance_classes.append(
77-
"https://api.stacspec.org/v1.0.0-rc.1/collection-search#filter"
78-
)
79-
8074
aggregation_extension = AggregationExtension(
8175
client=EsAsyncBaseAggregationClient(
8276
database=database_logic, session=session, settings=settings
@@ -95,7 +89,6 @@
9589
TokenPaginationExtension(),
9690
filter_extension,
9791
FreeTextExtension(),
98-
collection_search_extension,
9992
]
10093

10194

@@ -122,6 +115,26 @@
122115

123116
extensions = [aggregation_extension] + search_extensions
124117

118+
# Create collection search extensions
119+
# Only sort extension is enabled for now
120+
collection_search_extensions = [
121+
# QueryExtension(conformance_classes=[QueryConformanceClasses.COLLECTIONS]),
122+
SortExtension(conformance_classes=[SortConformanceClasses.COLLECTIONS]),
123+
# FieldsExtension(conformance_classes=[FieldsConformanceClasses.COLLECTIONS]),
124+
# CollectionSearchFilterExtension(
125+
# conformance_classes=[FilterConformanceClasses.COLLECTIONS]
126+
# ),
127+
# FreeTextExtension(conformance_classes=[FreeTextConformanceClasses.COLLECTIONS]),
128+
]
129+
130+
# Initialize collection search with its extensions
131+
collection_search_ext = CollectionSearchExtension.from_extensions(
132+
collection_search_extensions
133+
)
134+
collections_get_request_model = collection_search_ext.GET
135+
136+
extensions.append(collection_search_ext)
137+
125138
database_logic.extensions = [type(ext).__name__ for ext in extensions]
126139

127140
post_request_model = create_post_request_model(search_extensions)
@@ -154,6 +167,7 @@
154167
post_request_model=post_request_model,
155168
landing_page_id=os.getenv("STAC_FASTAPI_LANDING_PAGE_ID", "stac-fastapi"),
156169
),
170+
"collections_get_request_model": collections_get_request_model,
157171
"search_get_request_model": create_get_request_model(search_extensions),
158172
"search_post_request_model": post_request_model,
159173
"items_get_request_model": items_get_request_model,

stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -154,31 +154,47 @@ def __attrs_post_init__(self):
154154
"""CORE LOGIC"""
155155

156156
async def get_all_collections(
157-
self, token: Optional[str], limit: int, request: Request
157+
self,
158+
token: Optional[str],
159+
limit: int,
160+
request: Request,
161+
sort: Optional[List[Dict[str, Any]]] = None,
158162
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
159-
"""
160-
Retrieve a list of all collections from Opensearch, supporting pagination.
163+
"""Retrieve a list of collections from Elasticsearch, supporting pagination.
161164
162165
Args:
163166
token (Optional[str]): The pagination token.
164167
limit (int): The number of results to return.
168+
request (Request): The FastAPI request object.
169+
sort (Optional[List[Dict[str, Any]]]): Optional sort parameter from the request.
165170
166171
Returns:
167172
A tuple of (collections, next pagination token if any).
168173
"""
169-
search_body = {
170-
"sort": [{"id": {"order": "asc"}}],
174+
formatted_sort = []
175+
if sort:
176+
for item in sort:
177+
field = item.get("field")
178+
direction = item.get("direction", "asc")
179+
if field:
180+
formatted_sort.append({field: {"order": direction}})
181+
# Always include id as a secondary sort to ensure consistent pagination
182+
if not any("id" in item for item in formatted_sort):
183+
formatted_sort.append({"id": {"order": "asc"}})
184+
else:
185+
formatted_sort = [{"id": {"order": "asc"}}]
186+
187+
body = {
188+
"sort": formatted_sort,
171189
"size": limit,
172190
}
173191

174-
# Only add search_after to the query if token is not None and not empty
175192
if token:
176-
search_after = [token]
177-
search_body["search_after"] = search_after
193+
body["search_after"] = [token]
178194

179195
response = await self.client.search(
180196
index=COLLECTIONS_INDEX,
181-
body=search_body,
197+
body=body,
182198
)
183199

184200
hits = response["hits"]["hits"]

0 commit comments

Comments
 (0)