Skip to content

Commit 859732e

Browse files
authored
Add sort, query, filter extensions, functionality to item collection route (stac-utils#437)
**Related Issue(s):** - stac-utils#440 **Description:** - Sortby extension, functionality to the item collection route. - Filter extension, functionality to the item collection route. - Query extension, functionality to the item collection route. - Simplified the item_collection function in core.py, moving the request to the get_search function. - Fixed issue where sortby was not accepting the default sort, where a + or - was not specified before the field value ie. localhost:8081/collections/{collection_id}/items?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 7574013 commit 859732e

File tree

7 files changed

+319
-110
lines changed

7 files changed

+319
-110
lines changed

CHANGELOG.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,19 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1212

1313
- `STAC_INDEX_ASSETS` environment variable to allow asset serialization to be configurable. [#433](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/433)
1414
- Added the `ENV_MAX_LIMIT` environment variable to SFEOS, allowing overriding of the `MAX_LIMIT`, which controls the `?limit` parameter for returned items and STAC collections. [#434](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/434)
15-
- Updated the `format_datetime_range` function to support milliseconds. [#423](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/423)
15+
- Sort, Query, and Filter extension and functionality to the item collection route. [#437](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/437)
1616

1717
### Changed
1818

19-
- Changed assets serialization to prevent mapping explosion while allowing asset inforamtion to be indexed. [#341](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/341)
19+
- Changed assets serialization to prevent mapping explosion while allowing asset information to be indexed. [#341](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/341)
20+
- Simplified the item_collection function in core.py, moving the request to the get_search function. [#437](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/437)
21+
- Updated the `format_datetime_range` function to support milliseconds. [#423](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/423)
2022
- Blocked the /collections/{collection_id}/bulk_items endpoint when environmental variable ENABLE_DATETIME_INDEX_FILTERING is set to true. [#438](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/438)
2123

2224
### Fixed
2325

26+
- Fixed issue where sortby was not accepting the default sort, where a + or - was not specified before the field value ie. localhost:8081/collections/{collection_id}/items?sortby=id. [#437](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/437)
27+
2428
## [v6.2.1] - 2025-09-02
2529

2630
### Added

dockerfiles/Dockerfile.dev.os

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@ FROM python:3.10-slim
44
# update apt pkgs, and install build-essential for ciso8601
55
RUN apt-get update && \
66
apt-get -y upgrade && \
7-
apt-get -y install build-essential && \
7+
apt-get -y install build-essential git && \
88
apt-get clean && \
99
rm -rf /var/lib/apt/lists/*
1010

11-
RUN apt-get -y install git
1211
# update certs used by Requests
1312
ENV CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
1413

stac_fastapi/core/stac_fastapi/core/core.py

Lines changed: 71 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -284,86 +284,60 @@ async def get_collection(
284284
async def item_collection(
285285
self,
286286
collection_id: str,
287+
request: Request,
287288
bbox: Optional[BBox] = None,
288289
datetime: Optional[str] = None,
289290
limit: Optional[int] = None,
291+
sortby: Optional[str] = None,
292+
filter_expr: Optional[str] = None,
293+
filter_lang: Optional[str] = None,
290294
token: Optional[str] = None,
295+
query: Optional[str] = None,
291296
**kwargs,
292297
) -> stac_types.ItemCollection:
293-
"""Read items from a specific collection in the database.
298+
"""List items within a specific collection.
299+
300+
This endpoint delegates to ``get_search`` under the hood with
301+
``collections=[collection_id]`` so that filtering, sorting and pagination
302+
behave identically to the Search endpoints.
294303
295304
Args:
296-
collection_id (str): The identifier of the collection to read items from.
297-
bbox (Optional[BBox]): The bounding box to filter items by.
298-
datetime (Optional[str]): The datetime range to filter items by.
299-
limit (int): The maximum number of items to return.
300-
token (str): A token used for pagination.
301-
request (Request): The incoming request.
305+
collection_id (str): ID of the collection to list items from.
306+
request (Request): FastAPI Request object.
307+
bbox (Optional[BBox]): Optional bounding box filter.
308+
datetime (Optional[str]): Optional datetime or interval filter.
309+
limit (Optional[int]): Optional page size. Defaults to env ``STAC_ITEM_LIMIT`` when unset.
310+
sortby (Optional[str]): Optional sort specification. Accepts repeated values
311+
like ``sortby=-properties.datetime`` or ``sortby=+id``. Bare fields (e.g. ``sortby=id``)
312+
imply ascending order.
313+
token (Optional[str]): Optional pagination token.
314+
query (Optional[str]): Optional query string.
315+
filter_expr (Optional[str]): Optional filter expression.
316+
filter_lang (Optional[str]): Optional filter language.
302317
303318
Returns:
304-
ItemCollection: An `ItemCollection` object containing the items from the specified collection that meet
305-
the filter criteria and links to various resources.
319+
ItemCollection: Feature collection with items, paging links, and counts.
306320
307321
Raises:
308-
HTTPException: If the specified collection is not found.
309-
Exception: If any error occurs while reading the items from the database.
322+
HTTPException: 404 if the collection does not exist.
310323
"""
311-
request: Request = kwargs["request"]
312-
token = request.query_params.get("token")
313-
314-
base_url = str(request.base_url)
315-
316-
collection = await self.get_collection(
317-
collection_id=collection_id, request=request
318-
)
319-
collection_id = collection.get("id")
320-
if collection_id is None:
321-
raise HTTPException(status_code=404, detail="Collection not found")
322-
323-
search = self.database.make_search()
324-
search = self.database.apply_collections_filter(
325-
search=search, collection_ids=[collection_id]
326-
)
327-
328324
try:
329-
search, datetime_search = self.database.apply_datetime_filter(
330-
search=search, datetime=datetime
331-
)
332-
except (ValueError, TypeError) as e:
333-
# Handle invalid interval formats if return_date fails
334-
msg = f"Invalid interval format: {datetime}, error: {e}"
335-
logger.error(msg)
336-
raise HTTPException(status_code=400, detail=msg)
337-
338-
if bbox:
339-
bbox = [float(x) for x in bbox]
340-
if len(bbox) == 6:
341-
bbox = [bbox[0], bbox[1], bbox[3], bbox[4]]
342-
343-
search = self.database.apply_bbox_filter(search=search, bbox=bbox)
325+
await self.get_collection(collection_id=collection_id, request=request)
326+
except Exception:
327+
raise HTTPException(status_code=404, detail="Collection not found")
344328

345-
limit = int(request.query_params.get("limit", os.getenv("STAC_ITEM_LIMIT", 10)))
346-
items, maybe_count, next_token = await self.database.execute_search(
347-
search=search,
329+
# Delegate directly to GET search for consistency
330+
return await self.get_search(
331+
request=request,
332+
collections=[collection_id],
333+
bbox=bbox,
334+
datetime=datetime,
348335
limit=limit,
349-
sort=None,
350336
token=token,
351-
collection_ids=[collection_id],
352-
datetime_search=datetime_search,
353-
)
354-
355-
items = [
356-
self.item_serializer.db_to_stac(item, base_url=base_url) for item in items
357-
]
358-
359-
links = await PagingLinks(request=request, next=next_token).get_links()
360-
361-
return stac_types.ItemCollection(
362-
type="FeatureCollection",
363-
features=items,
364-
links=links,
365-
numReturned=len(items),
366-
numMatched=maybe_count,
337+
sortby=sortby,
338+
query=query,
339+
filter_expr=filter_expr,
340+
filter_lang=filter_lang,
367341
)
368342

369343
async def get_item(
@@ -429,6 +403,7 @@ async def get_search(
429403
HTTPException: If any error occurs while searching the catalog.
430404
"""
431405
limit = int(request.query_params.get("limit", os.getenv("STAC_ITEM_LIMIT", 10)))
406+
432407
base_args = {
433408
"collections": collections,
434409
"ids": ids,
@@ -446,10 +421,18 @@ async def get_search(
446421
base_args["intersects"] = orjson.loads(unquote_plus(intersects))
447422

448423
if sortby:
449-
base_args["sortby"] = [
450-
{"field": sort[1:], "direction": "desc" if sort[0] == "-" else "asc"}
451-
for sort in sortby
452-
]
424+
parsed_sort = []
425+
for raw in sortby:
426+
if not isinstance(raw, str):
427+
continue
428+
s = raw.strip()
429+
if not s:
430+
continue
431+
direction = "desc" if s[0] == "-" else "asc"
432+
field = s[1:] if s and s[0] in "+-" else s
433+
parsed_sort.append({"field": field, "direction": direction})
434+
if parsed_sort:
435+
base_args["sortby"] = parsed_sort
453436

454437
if filter_expr:
455438
base_args["filter_lang"] = "cql2-json"
@@ -526,13 +509,15 @@ async def post_search(
526509

527510
search = self.database.apply_bbox_filter(search=search, bbox=bbox)
528511

529-
if search_request.intersects:
512+
if hasattr(search_request, "intersects") and getattr(
513+
search_request, "intersects"
514+
):
530515
search = self.database.apply_intersects_filter(
531-
search=search, intersects=search_request.intersects
516+
search=search, intersects=getattr(search_request, "intersects")
532517
)
533518

534-
if search_request.query:
535-
for field_name, expr in search_request.query.items():
519+
if hasattr(search_request, "query") and getattr(search_request, "query"):
520+
for field_name, expr in getattr(search_request, "query").items():
536521
field = "properties__" + field_name
537522
for op, value in expr.items():
538523
# Convert enum to string
@@ -541,9 +526,14 @@ async def post_search(
541526
search=search, op=operator, field=field, value=value
542527
)
543528

544-
# only cql2_json is supported here
529+
# Apply CQL2 filter (support both 'filter_expr' and canonical 'filter')
530+
cql2_filter = None
545531
if hasattr(search_request, "filter_expr"):
546532
cql2_filter = getattr(search_request, "filter_expr", None)
533+
if cql2_filter is None and hasattr(search_request, "filter"):
534+
cql2_filter = getattr(search_request, "filter", None)
535+
536+
if cql2_filter is not None:
547537
try:
548538
search = await self.database.apply_cql2_filter(search, cql2_filter)
549539
except Exception as e:
@@ -561,19 +551,23 @@ async def post_search(
561551
)
562552

563553
sort = None
564-
if search_request.sortby:
565-
sort = self.database.populate_sort(search_request.sortby)
554+
if hasattr(search_request, "sortby") and getattr(search_request, "sortby"):
555+
sort = self.database.populate_sort(getattr(search_request, "sortby"))
566556

567557
limit = 10
568558
if search_request.limit:
569559
limit = search_request.limit
570560

561+
# Use token from the request if the model doesn't define it
562+
token_param = getattr(
563+
search_request, "token", None
564+
) or request.query_params.get("token")
571565
items, maybe_count, next_token = await self.database.execute_search(
572566
search=search,
573567
limit=limit,
574-
token=search_request.token,
568+
token=token_param,
575569
sort=sort,
576-
collection_ids=search_request.collections,
570+
collection_ids=getattr(search_request, "collections", None),
577571
datetime_search=datetime_search,
578572
)
579573

@@ -917,7 +911,7 @@ async def delete_collection(self, collection_id: str, **kwargs) -> None:
917911

918912
@attr.s
919913
class BulkTransactionsClient(BaseBulkTransactionsClient):
920-
"""A client for posting bulk transactions to a Postgres database.
914+
"""A client for posting bulk transactions.
921915
922916
Attributes:
923917
session: An instance of `Session` to use for database connection.

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
from fastapi import FastAPI
88

99
from stac_fastapi.api.app import StacApi
10-
from stac_fastapi.api.models import create_get_request_model, create_post_request_model
10+
from stac_fastapi.api.models import (
11+
ItemCollectionUri,
12+
create_get_request_model,
13+
create_post_request_model,
14+
create_request_model,
15+
)
1116
from stac_fastapi.core.core import (
1217
BulkTransactionsClient,
1318
CoreClient,
@@ -39,6 +44,8 @@
3944
TransactionExtension,
4045
)
4146
from stac_fastapi.extensions.core.filter import FilterConformanceClasses
47+
from stac_fastapi.extensions.core.query import QueryConformanceClasses
48+
from stac_fastapi.extensions.core.sort import SortConformanceClasses
4249
from stac_fastapi.extensions.third_party import BulkTransactionExtension
4350
from stac_fastapi.sfeos_helpers.aggregation import EsAsyncBaseAggregationClient
4451
from stac_fastapi.sfeos_helpers.filter import EsAsyncBaseFiltersClient
@@ -54,6 +61,7 @@
5461

5562
database_logic = DatabaseLogic()
5663

64+
5765
filter_extension = FilterExtension(
5866
client=EsAsyncBaseFiltersClient(database=database_logic)
5967
)
@@ -114,6 +122,21 @@
114122

115123
post_request_model = create_post_request_model(search_extensions)
116124

125+
items_get_request_model = create_request_model(
126+
model_name="ItemCollectionUri",
127+
base_model=ItemCollectionUri,
128+
extensions=[
129+
SortExtension(
130+
conformance_classes=[SortConformanceClasses.ITEMS],
131+
),
132+
QueryExtension(
133+
conformance_classes=[QueryConformanceClasses.ITEMS],
134+
),
135+
filter_extension,
136+
],
137+
request_type="GET",
138+
)
139+
117140
app_config = {
118141
"title": os.getenv("STAC_FASTAPI_TITLE", "stac-fastapi-elasticsearch"),
119142
"description": os.getenv("STAC_FASTAPI_DESCRIPTION", "stac-fastapi-elasticsearch"),
@@ -128,6 +151,7 @@
128151
),
129152
"search_get_request_model": create_get_request_model(search_extensions),
130153
"search_post_request_model": post_request_model,
154+
"items_get_request_model": items_get_request_model,
131155
"route_dependencies": get_route_dependencies(),
132156
}
133157

stac_fastapi/opensearch/stac_fastapi/opensearch/app.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
from fastapi import FastAPI
88

99
from stac_fastapi.api.app import StacApi
10-
from stac_fastapi.api.models import create_get_request_model, create_post_request_model
10+
from stac_fastapi.api.models import (
11+
ItemCollectionUri,
12+
create_get_request_model,
13+
create_post_request_model,
14+
create_request_model,
15+
)
1116
from stac_fastapi.core.core import (
1217
BulkTransactionsClient,
1318
CoreClient,
@@ -33,6 +38,8 @@
3338
TransactionExtension,
3439
)
3540
from stac_fastapi.extensions.core.filter import FilterConformanceClasses
41+
from stac_fastapi.extensions.core.query import QueryConformanceClasses
42+
from stac_fastapi.extensions.core.sort import SortConformanceClasses
3643
from stac_fastapi.extensions.third_party import BulkTransactionExtension
3744
from stac_fastapi.opensearch.config import OpensearchSettings
3845
from stac_fastapi.opensearch.database_logic import (
@@ -115,6 +122,21 @@
115122

116123
post_request_model = create_post_request_model(search_extensions)
117124

125+
items_get_request_model = create_request_model(
126+
model_name="ItemCollectionUri",
127+
base_model=ItemCollectionUri,
128+
extensions=[
129+
SortExtension(
130+
conformance_classes=[SortConformanceClasses.ITEMS],
131+
),
132+
QueryExtension(
133+
conformance_classes=[QueryConformanceClasses.ITEMS],
134+
),
135+
filter_extension,
136+
],
137+
request_type="GET",
138+
)
139+
118140
app_config = {
119141
"title": os.getenv("STAC_FASTAPI_TITLE", "stac-fastapi-opensearch"),
120142
"description": os.getenv("STAC_FASTAPI_DESCRIPTION", "stac-fastapi-opensearch"),
@@ -129,6 +151,7 @@
129151
),
130152
"search_get_request_model": create_get_request_model(search_extensions),
131153
"search_post_request_model": post_request_model,
154+
"items_get_request_model": items_get_request_model,
132155
"route_dependencies": get_route_dependencies(),
133156
}
134157

0 commit comments

Comments
 (0)