Skip to content

Commit d3a432d

Browse files
committed
Merge branch 'sort-item-collection' into collections-items-fields-extension
# Conflicts: # CHANGELOG.md # stac_fastapi/core/stac_fastapi/core/core.py
2 parents f5ac531 + 7355a80 commit d3a432d

File tree

12 files changed

+353
-118
lines changed

12 files changed

+353
-118
lines changed

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,21 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1010

1111
### Added
1212

13+
- 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)
14+
- Sort, Query, and Filter extension and functionality to the item collection route. [#437](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/437)
1315
- Added Fields Extension implementation for the `/collections/{collection_id}/aggregations` endpoint. [#436](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/436)
1416

1517
### Changed
1618

17-
- 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)
22+
- 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)
1823

1924
### Fixed
2025

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+
2128
## [v6.2.1] - 2025-09-02
2229

2330
### Added

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ You can customize additional settings in your `.env` file:
228228
| `DATABASE_REFRESH` | Controls whether database operations refresh the index immediately after changes. If set to `true`, changes will be immediately searchable. If set to `false`, changes may not be immediately visible but can improve performance for bulk operations. If set to `wait_for`, changes will wait for the next refresh cycle to become visible. | `false` | Optional |
229229
| `ENABLE_TRANSACTIONS_EXTENSIONS` | Enables or disables the Transactions and Bulk Transactions API extensions. If set to `false`, the POST `/collections` route and related transaction endpoints (including bulk transaction operations) will be unavailable in the API. This is useful for deployments where mutating the catalog via the API should be prevented. | `true` | Optional |
230230
| `STAC_ITEM_LIMIT` | Sets the environment variable for result limiting to SFEOS for the number of returned items and STAC collections. | `10` | Optional |
231+
| `ENV_MAX_LIMIT` | Configures the environment variable in SFEOS to override the default `MAX_LIMIT`, which controls the limit parameter for returned items and STAC collections. | `10,000` | Optional |
231232

232233
> [!NOTE]
233234
> The variables `ES_HOST`, `ES_PORT`, `ES_USE_SSL`, `ES_VERIFY_CERTS` and `ES_TIMEOUT` apply to both Elasticsearch and OpenSearch backends, so there is no need to rename the key names to `OS_` even if you're using OpenSearch.

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: 78 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -284,106 +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-
fields = request.query_params.get("fields")
356-
if fields and self.extension_is_enabled("FieldsExtension"):
357-
fields = fields.split(",")
358-
includes, excludes = set(), set()
359-
for field in fields:
360-
if field[0] == "-":
361-
excludes.add(field[1:])
362-
else:
363-
includes.add(field[1:] if field[0] in "+ " else field)
364-
365-
items = [
366-
filter_fields(
367-
self.item_serializer.db_to_stac(item, base_url=base_url),
368-
includes,
369-
excludes,
370-
)
371-
for item in items
372-
]
373-
else:
374-
items = [
375-
self.item_serializer.db_to_stac(item, base_url=base_url)
376-
for item in items
377-
]
378-
379-
links = await PagingLinks(request=request, next=next_token).get_links()
380-
381-
return stac_types.ItemCollection(
382-
type="FeatureCollection",
383-
features=items,
384-
links=links,
385-
numReturned=len(items),
386-
numMatched=maybe_count,
337+
sortby=sortby,
338+
query=query,
339+
filter_expr=filter_expr,
340+
filter_lang=filter_lang,
387341
)
388342

389343
async def get_item(
@@ -449,6 +403,7 @@ async def get_search(
449403
HTTPException: If any error occurs while searching the catalog.
450404
"""
451405
limit = int(request.query_params.get("limit", os.getenv("STAC_ITEM_LIMIT", 10)))
406+
452407
base_args = {
453408
"collections": collections,
454409
"ids": ids,
@@ -466,10 +421,18 @@ async def get_search(
466421
base_args["intersects"] = orjson.loads(unquote_plus(intersects))
467422

468423
if sortby:
469-
base_args["sortby"] = [
470-
{"field": sort[1:], "direction": "desc" if sort[0] == "-" else "asc"}
471-
for sort in sortby
472-
]
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
473436

474437
if filter_expr:
475438
base_args["filter_lang"] = "cql2-json"
@@ -546,13 +509,15 @@ async def post_search(
546509

547510
search = self.database.apply_bbox_filter(search=search, bbox=bbox)
548511

549-
if search_request.intersects:
512+
if hasattr(search_request, "intersects") and getattr(
513+
search_request, "intersects"
514+
):
550515
search = self.database.apply_intersects_filter(
551-
search=search, intersects=search_request.intersects
516+
search=search, intersects=getattr(search_request, "intersects")
552517
)
553518

554-
if search_request.query:
555-
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():
556521
field = "properties__" + field_name
557522
for op, value in expr.items():
558523
# Convert enum to string
@@ -561,9 +526,14 @@ async def post_search(
561526
search=search, op=operator, field=field, value=value
562527
)
563528

564-
# only cql2_json is supported here
529+
# Apply CQL2 filter (support both 'filter_expr' and canonical 'filter')
530+
cql2_filter = None
565531
if hasattr(search_request, "filter_expr"):
566532
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:
567537
try:
568538
search = await self.database.apply_cql2_filter(search, cql2_filter)
569539
except Exception as e:
@@ -581,19 +551,23 @@ async def post_search(
581551
)
582552

583553
sort = None
584-
if search_request.sortby:
585-
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"))
586556

587557
limit = 10
588558
if search_request.limit:
589559
limit = search_request.limit
590560

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")
591565
items, maybe_count, next_token = await self.database.execute_search(
592566
search=search,
593567
limit=limit,
594-
token=search_request.token,
568+
token=token_param,
595569
sort=sort,
596-
collection_ids=search_request.collections,
570+
collection_ids=getattr(search_request, "collections", None),
597571
datetime_search=datetime_search,
598572
)
599573

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

938912
@attr.s
939913
class BulkTransactionsClient(BaseBulkTransactionsClient):
940-
"""A client for posting bulk transactions to a Postgres database.
914+
"""A client for posting bulk transactions.
941915
942916
Attributes:
943917
session: An instance of `Session` to use for database connection.
@@ -985,6 +959,13 @@ def bulk_item_insert(
985959
A string indicating the number of items successfully added.
986960
"""
987961
request = kwargs.get("request")
962+
963+
if os.getenv("ENABLE_DATETIME_INDEX_FILTERING"):
964+
raise HTTPException(
965+
status_code=400,
966+
detail="The /collections/{collection_id}/bulk_items endpoint is invalid when ENABLE_DATETIME_INDEX_FILTERING is set to true. Try using the /collections/{collection_id}/items endpoint.",
967+
)
968+
988969
if request:
989970
base_url = str(request.base_url)
990971
else:

stac_fastapi/core/stac_fastapi/core/datetime_utils.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,20 @@ def format_datetime_range(date_str: str) -> str:
1717
"""
1818

1919
def normalize(dt):
20+
"""Normalize datetime string and preserve millisecond precision."""
2021
dt = dt.strip()
2122
if not dt or dt == "..":
2223
return ".."
2324
dt_obj = rfc3339_str_to_datetime(dt)
2425
dt_utc = dt_obj.astimezone(timezone.utc)
25-
return dt_utc.strftime("%Y-%m-%dT%H:%M:%SZ")
26+
return dt_utc.isoformat(timespec="milliseconds").replace("+00:00", "Z")
2627

2728
if not isinstance(date_str, str):
2829
return "../.."
30+
2931
if "/" not in date_str:
3032
return f"{normalize(date_str)}/{normalize(date_str)}"
33+
3134
try:
3235
start, end = date_str.split("/", 1)
3336
except Exception:

stac_fastapi/core/stac_fastapi/core/utilities.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,15 @@
1010

1111
from stac_fastapi.types.stac import Item
1212

13-
MAX_LIMIT = 10000
13+
14+
def get_max_limit():
15+
"""
16+
Retrieve a MAX_LIMIT value from an environment variable.
17+
18+
Returns:
19+
int: The int value parsed from the environment variable.
20+
"""
21+
return int(os.getenv("ENV_MAX_LIMIT", 10000))
1422

1523

1624
def get_bool_env(name: str, default: Union[bool, str] = False) -> bool:

0 commit comments

Comments
 (0)