From e24498ffa9880cd698afc1d4944f38d32b10ba48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zuzanna=20Mr=C3=B3z?= Date: Tue, 9 Sep 2025 11:39:18 +0200 Subject: [PATCH 01/30] Revert "Enabling asset indexing (#341)" This reverts commit 8974c384280f819272e4ff415acdcb330312dd69. --- CHANGELOG.md | 2 -- stac_fastapi/core/stac_fastapi/core/serializers.py | 13 +------------ .../stac_fastapi/sfeos_helpers/mappings.py | 2 +- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21d9fd130..130f5df61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed -- 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) - ### Fixed ## [v6.2.1] - 2025-09-02 diff --git a/stac_fastapi/core/stac_fastapi/core/serializers.py b/stac_fastapi/core/stac_fastapi/core/serializers.py index 22546703d..d537b4938 100644 --- a/stac_fastapi/core/stac_fastapi/core/serializers.py +++ b/stac_fastapi/core/stac_fastapi/core/serializers.py @@ -66,10 +66,6 @@ def stac_to_db(cls, stac_data: stac_types.Item, base_url: str) -> stac_types.Ite item_links = resolve_links(stac_data.get("links", []), base_url) stac_data["links"] = item_links - stac_data["assets"] = [ - {"es_key": k, **v} for k, v in stac_data.get("assets", {}).items() - ] - now = now_to_rfc3339_str() if "created" not in stac_data["properties"]: stac_data["properties"]["created"] = now @@ -107,7 +103,7 @@ def db_to_stac(cls, item: dict, base_url: str) -> stac_types.Item: bbox=item.get("bbox", []), properties=item.get("properties", {}), links=item_links, - assets={a.pop("es_key"): a for a in item.get("assets", [])}, + assets=item.get("assets", {}), ) @@ -132,9 +128,6 @@ def stac_to_db( collection["links"] = resolve_links( collection.get("links", []), str(request.base_url) ) - collection["assets"] = [ - {"es_key": k, **v} for k, v in collection.get("assets", {}).items() - ] return collection @classmethod @@ -181,9 +174,5 @@ def db_to_stac( collection_links += resolve_links(original_links, str(request.base_url)) collection["links"] = collection_links - collection["assets"] = { - a.pop("es_key"): a for a in collection.get("assets", []) - } - # Return the stac_types.Collection object return stac_types.Collection(**collection) diff --git a/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/mappings.py b/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/mappings.py index 935aa84bf..476d656a1 100644 --- a/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/mappings.py +++ b/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/mappings.py @@ -134,7 +134,7 @@ class Geometry(Protocol): # noqa "id": {"type": "keyword"}, "collection": {"type": "keyword"}, "geometry": {"type": "geo_shape"}, - "assets": {"type": "object"}, + "assets": {"type": "object", "enabled": False}, "links": {"type": "object", "enabled": False}, "properties": { "type": "object", From 99ed33665ce152475d28f228ef51fcaebe009d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zuzanna=20Mr=C3=B3z?= Date: Tue, 9 Sep 2025 12:17:32 +0200 Subject: [PATCH 02/30] collections/{collection}/items fields extension implementation --- stac_fastapi/core/stac_fastapi/core/core.py | 26 ++++++++++++++++--- .../stac_fastapi/elasticsearch/app.py | 6 ++++- .../opensearch/stac_fastapi/opensearch/app.py | 6 ++++- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index 8e7da91bc..04869a671 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -352,9 +352,29 @@ async def item_collection( datetime_search=datetime_search, ) - items = [ - self.item_serializer.db_to_stac(item, base_url=base_url) for item in items - ] + fields = request.query_params.get("fields") + if fields and self.extension_is_enabled("FieldsExtension"): + fields = fields.split(",") + includes, excludes = set(), set() + for field in fields: + if field[0] == "-": + excludes.add(field[1:]) + else: + includes.add(field[1:] if field[0] in "+ " else field) + + items = [ + filter_fields( + self.item_serializer.db_to_stac(item, base_url=base_url), + includes, + excludes, + ) + for item in items + ] + else: + items = [ + self.item_serializer.db_to_stac(item, base_url=base_url) + for item in items + ] links = await PagingLinks(request=request, next=next_token).get_links() diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py index b0fbbd6b9..30a9dab3f 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py @@ -38,6 +38,7 @@ TokenPaginationExtension, TransactionExtension, ) +from stac_fastapi.extensions.core.fields import FieldsConformanceClasses from stac_fastapi.extensions.core.filter import FilterConformanceClasses from stac_fastapi.extensions.third_party import BulkTransactionExtension from stac_fastapi.sfeos_helpers.aggregation import EsAsyncBaseAggregationClient @@ -77,8 +78,11 @@ aggregation_extension.POST = EsAggregationExtensionPostRequest aggregation_extension.GET = EsAggregationExtensionGetRequest +fields_extension = FieldsExtension() +fields_extension.conformance_classes.append(FieldsConformanceClasses.ITEMS) + search_extensions = [ - FieldsExtension(), + fields_extension, QueryExtension(), SortExtension(), TokenPaginationExtension(), diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py index 1fa036d1b..5bf00c8ac 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py @@ -32,6 +32,7 @@ TokenPaginationExtension, TransactionExtension, ) +from stac_fastapi.extensions.core.fields import FieldsConformanceClasses from stac_fastapi.extensions.core.filter import FilterConformanceClasses from stac_fastapi.extensions.third_party import BulkTransactionExtension from stac_fastapi.opensearch.config import OpensearchSettings @@ -77,8 +78,11 @@ aggregation_extension.POST = EsAggregationExtensionPostRequest aggregation_extension.GET = EsAggregationExtensionGetRequest +fields_extension = FieldsExtension() +fields_extension.conformance_classes.append(FieldsConformanceClasses.ITEMS) + search_extensions = [ - FieldsExtension(), + fields_extension, QueryExtension(), SortExtension(), TokenPaginationExtension(), From db3c9badbd91fae3b4ec1e4c1e3e47476991c0cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zuzanna=20Mr=C3=B3z?= Date: Tue, 9 Sep 2025 12:20:22 +0200 Subject: [PATCH 03/30] Revert "Revert "Enabling asset indexing (#341)"" This reverts commit e24498ffa9880cd698afc1d4944f38d32b10ba48. --- CHANGELOG.md | 2 ++ stac_fastapi/core/stac_fastapi/core/serializers.py | 13 ++++++++++++- .../stac_fastapi/sfeos_helpers/mappings.py | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 130f5df61..21d9fd130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed +- 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) + ### Fixed ## [v6.2.1] - 2025-09-02 diff --git a/stac_fastapi/core/stac_fastapi/core/serializers.py b/stac_fastapi/core/stac_fastapi/core/serializers.py index d537b4938..22546703d 100644 --- a/stac_fastapi/core/stac_fastapi/core/serializers.py +++ b/stac_fastapi/core/stac_fastapi/core/serializers.py @@ -66,6 +66,10 @@ def stac_to_db(cls, stac_data: stac_types.Item, base_url: str) -> stac_types.Ite item_links = resolve_links(stac_data.get("links", []), base_url) stac_data["links"] = item_links + stac_data["assets"] = [ + {"es_key": k, **v} for k, v in stac_data.get("assets", {}).items() + ] + now = now_to_rfc3339_str() if "created" not in stac_data["properties"]: stac_data["properties"]["created"] = now @@ -103,7 +107,7 @@ def db_to_stac(cls, item: dict, base_url: str) -> stac_types.Item: bbox=item.get("bbox", []), properties=item.get("properties", {}), links=item_links, - assets=item.get("assets", {}), + assets={a.pop("es_key"): a for a in item.get("assets", [])}, ) @@ -128,6 +132,9 @@ def stac_to_db( collection["links"] = resolve_links( collection.get("links", []), str(request.base_url) ) + collection["assets"] = [ + {"es_key": k, **v} for k, v in collection.get("assets", {}).items() + ] return collection @classmethod @@ -174,5 +181,9 @@ def db_to_stac( collection_links += resolve_links(original_links, str(request.base_url)) collection["links"] = collection_links + collection["assets"] = { + a.pop("es_key"): a for a in collection.get("assets", []) + } + # Return the stac_types.Collection object return stac_types.Collection(**collection) diff --git a/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/mappings.py b/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/mappings.py index 476d656a1..935aa84bf 100644 --- a/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/mappings.py +++ b/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/mappings.py @@ -134,7 +134,7 @@ class Geometry(Protocol): # noqa "id": {"type": "keyword"}, "collection": {"type": "keyword"}, "geometry": {"type": "geo_shape"}, - "assets": {"type": "object", "enabled": False}, + "assets": {"type": "object"}, "links": {"type": "object", "enabled": False}, "properties": { "type": "object", From 67d1cb946b16c810a02346d0a09f4e377dfa524b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zuzanna=20Mr=C3=B3z?= Date: Thu, 11 Sep 2025 10:32:31 +0200 Subject: [PATCH 04/30] tests --- CHANGELOG.md | 2 + stac_fastapi/tests/api/test_api.py | 68 +++++++++++++++++++++++ stac_fastapi/tests/conftest.py | 6 +- stac_fastapi/tests/resources/test_item.py | 29 ++++++++++ 4 files changed, 104 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21d9fd130..74fbebbd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added +- Added Fields Extension implementation for the `/collections/{collection_id}/aggregations` endpoint. + ### Changed - 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) diff --git a/stac_fastapi/tests/api/test_api.py b/stac_fastapi/tests/api/test_api.py index c07efbd2e..baad4c5f5 100644 --- a/stac_fastapi/tests/api/test_api.py +++ b/stac_fastapi/tests/api/test_api.py @@ -228,6 +228,74 @@ async def test_app_fields_extension_return_all_properties( assert feature["properties"][expected_prop] == expected_value +@pytest.mark.asyncio +async def test_app_fields_extension_collection_items(app_client, ctx, txn_client): + resp = await app_client.get( + "/collections/test-collection/items", + params={"fields": "+properties.datetime"}, + ) + assert resp.status_code == 200 + resp_json = resp.json() + assert list(resp_json["features"][0]["properties"]) == ["datetime"] + + +@pytest.mark.asyncio +async def test_app_fields_extension_no_properties_get_collection_items( + app_client, ctx, txn_client +): + resp = await app_client.get( + "/collections/test-collection/items", params={"fields": "-properties"} + ) + assert resp.status_code == 200 + resp_json = resp.json() + assert "properties" not in resp_json["features"][0] + + +@pytest.mark.asyncio +async def test_app_fields_extension_no_null_fields_collection_items( + app_client, ctx, txn_client +): + resp = await app_client.get("/collections/test-collection/items") + assert resp.status_code == 200 + resp_json = resp.json() + # check if no null fields: https://github.com/stac-utils/stac-fastapi-elasticsearch/issues/166 + for feature in resp_json["features"]: + # assert "bbox" not in feature["geometry"] + for link in feature["links"]: + assert all(a not in link or link[a] is not None for a in ("title", "asset")) + for asset in feature["assets"]: + assert all( + a not in asset or asset[a] is not None + for a in ("start_datetime", "created") + ) + + +@pytest.mark.asyncio +async def test_app_fields_extension_return_all_properties_collection_items( + app_client, ctx, txn_client, load_test_data +): + item = load_test_data("test_item.json") + resp = await app_client.get( + "/collections/test-collection/items", + params={"collections": ["test-collection"], "fields": "properties"}, + ) + assert resp.status_code == 200 + resp_json = resp.json() + feature = resp_json["features"][0] + assert len(feature["properties"]) >= len(item["properties"]) + for expected_prop, expected_value in item["properties"].items(): + if expected_prop in ( + "datetime", + "start_datetime", + "end_datetime", + "created", + "updated", + ): + assert feature["properties"][expected_prop][0:19] == expected_value[0:19] + else: + assert feature["properties"][expected_prop] == expected_value + + @pytest.mark.asyncio async def test_app_query_extension_gt(app_client, ctx): params = {"query": {"proj:epsg": {"gt": ctx.item["properties"]["proj:epsg"]}}} diff --git a/stac_fastapi/tests/conftest.py b/stac_fastapi/tests/conftest.py index 23da26687..343adfb8b 100644 --- a/stac_fastapi/tests/conftest.py +++ b/stac_fastapi/tests/conftest.py @@ -25,6 +25,7 @@ ) from stac_fastapi.core.rate_limit import setup_rate_limit from stac_fastapi.core.utilities import get_bool_env +from stac_fastapi.extensions.core.fields import FieldsConformanceClasses from stac_fastapi.sfeos_helpers.aggregation import EsAsyncBaseAggregationClient from stac_fastapi.sfeos_helpers.mappings import ITEMS_INDEX_PREFIX @@ -361,9 +362,12 @@ def build_test_app(): aggregation_extension.POST = EsAggregationExtensionPostRequest aggregation_extension.GET = EsAggregationExtensionGetRequest + fields_extension = FieldsExtension() + fields_extension.conformance_classes.append(FieldsConformanceClasses.ITEMS) + search_extensions = [ + fields_extension, SortExtension(), - FieldsExtension(), QueryExtension(), TokenPaginationExtension(), FilterExtension(), diff --git a/stac_fastapi/tests/resources/test_item.py b/stac_fastapi/tests/resources/test_item.py index 0299cdc00..7ba563df8 100644 --- a/stac_fastapi/tests/resources/test_item.py +++ b/stac_fastapi/tests/resources/test_item.py @@ -869,6 +869,35 @@ async def test_field_extension_exclude_default_includes(app_client, ctx): assert "gsd" not in resp_json["features"][0] +@pytest.mark.asyncio +async def test_field_extension_get_includes_collection_items(app_client, ctx): + """Test GET collections/{collection_id}/items with included fields (fields extension)""" + test_item = ctx.item + params = { + "fields": "+properties.proj:epsg,+properties.gsd", + } + resp = await app_client.get( + f"/collections/{test_item['collection']}/items", params=params + ) + feat_properties = resp.json()["features"][0]["properties"] + assert not set(feat_properties) - {"proj:epsg", "gsd", "datetime"} + + +@pytest.mark.asyncio +async def test_field_extension_get_excludes_collection_items(app_client, ctx): + """Test GET collections/{collection_id}/items with included fields (fields extension)""" + test_item = ctx.item + params = { + "fields": "-properties.proj:epsg,-properties.gsd", + } + resp = await app_client.get( + f"/collections/{test_item['collection']}/items", params=params + ) + resp_json = resp.json() + assert "proj:epsg" not in resp_json["features"][0]["properties"].keys() + assert "gsd" not in resp_json["features"][0]["properties"].keys() + + @pytest.mark.asyncio async def test_search_intersects_and_bbox(app_client): """Test POST search intersects and bbox are mutually exclusive (core)""" From f5ac531adef0ff793e4a549fb29d8d65885f9ca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zuzanna=20Mr=C3=B3z?= Date: Thu, 11 Sep 2025 11:42:09 +0200 Subject: [PATCH 05/30] changelog update --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74fbebbd5..435b94669 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added -- Added Fields Extension implementation for the `/collections/{collection_id}/aggregations` endpoint. +- Added Fields Extension implementation for the `/collections/{collection_id}/aggregations` endpoint. [#436](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/436) ### Changed From 58725eadca47f294dd2f3d097e02ca51181d5827 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Thu, 11 Sep 2025 20:01:55 +0800 Subject: [PATCH 06/30] add sort to item collection route --- CHANGELOG.md | 4 ++++ stac_fastapi/core/stac_fastapi/core/core.py | 16 ++++++++++++-- .../stac_fastapi/elasticsearch/app.py | 21 ++++++++++++++++++- .../sfeos_helpers/database/query.py | 1 + 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21d9fd130..ff60dc050 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added +- Sortby functionality to the item collection route. + ### Changed - 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) ### Fixed +- 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. + ## [v6.2.1] - 2025-09-02 ### Added diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index 8e7da91bc..5a16920d5 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -5,6 +5,7 @@ from datetime import datetime as datetime_type from datetime import timezone from enum import Enum +from types import SimpleNamespace from typing import List, Optional, Set, Type, Union from urllib.parse import unquote_plus, urljoin @@ -287,6 +288,7 @@ async def item_collection( bbox: Optional[BBox] = None, datetime: Optional[str] = None, limit: Optional[int] = None, + sortby: Optional[str] = None, token: Optional[str] = None, **kwargs, ) -> stac_types.ItemCollection: @@ -296,7 +298,7 @@ async def item_collection( collection_id (str): The identifier of the collection to read items from. bbox (Optional[BBox]): The bounding box to filter items by. datetime (Optional[str]): The datetime range to filter items by. - limit (int): The maximum number of items to return. + sortby (Optional[str]]): Sort spec like "-datetime". Bare fields imply ascending. token (str): A token used for pagination. request (Request): The incoming request. @@ -313,6 +315,16 @@ async def item_collection( base_url = str(request.base_url) + es_sort = None + if sortby: + specs = [] + for s in sortby: + field = s[1:] + direction = "desc" if s[0] == "-" else "asc" + specs.append(SimpleNamespace(field=field, direction=direction)) + if specs: + es_sort = self.database.populate_sort(specs) + collection = await self.get_collection( collection_id=collection_id, request=request ) @@ -346,7 +358,7 @@ async def item_collection( items, maybe_count, next_token = await self.database.execute_search( search=search, limit=limit, - sort=None, + sort=es_sort, token=token, collection_ids=[collection_id], datetime_search=datetime_search, diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py index b0fbbd6b9..7ee4698e6 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py @@ -7,7 +7,12 @@ from fastapi import FastAPI from stac_fastapi.api.app import StacApi -from stac_fastapi.api.models import create_get_request_model, create_post_request_model +from stac_fastapi.api.models import ( + ItemCollectionUri, + create_get_request_model, + create_post_request_model, + create_request_model, +) from stac_fastapi.core.core import ( BulkTransactionsClient, CoreClient, @@ -39,6 +44,7 @@ TransactionExtension, ) from stac_fastapi.extensions.core.filter import FilterConformanceClasses +from stac_fastapi.extensions.core.sort import SortConformanceClasses from stac_fastapi.extensions.third_party import BulkTransactionExtension from stac_fastapi.sfeos_helpers.aggregation import EsAsyncBaseAggregationClient from stac_fastapi.sfeos_helpers.filter import EsAsyncBaseFiltersClient @@ -54,6 +60,7 @@ database_logic = DatabaseLogic() + filter_extension = FilterExtension( client=EsAsyncBaseFiltersClient(database=database_logic) ) @@ -114,6 +121,17 @@ post_request_model = create_post_request_model(search_extensions) +items_get_request_model = create_request_model( + model_name="ItemCollectionUri", + base_model=ItemCollectionUri, + extensions=[ + SortExtension( + conformance_classes=[SortConformanceClasses.ITEMS], + ) + ], + request_type="GET", +) + app_config = { "title": os.getenv("STAC_FASTAPI_TITLE", "stac-fastapi-elasticsearch"), "description": os.getenv("STAC_FASTAPI_DESCRIPTION", "stac-fastapi-elasticsearch"), @@ -128,6 +146,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, "route_dependencies": get_route_dependencies(), } diff --git a/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database/query.py b/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database/query.py index 80d071287..84578fbf7 100644 --- a/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database/query.py +++ b/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database/query.py @@ -82,6 +82,7 @@ def populate_sort_shared(sortby: List) -> Optional[Dict[str, Dict[str, str]]]: directly used in search requests. Always includes 'id' as secondary sort to ensure unique pagination tokens. """ + print("sortbx: ", sortby) if sortby: sort_config = {s.field: {"order": s.direction} for s in sortby} sort_config.setdefault("id", {"order": "asc"}) From 6f8b161eb56b2a271b000328216f30dbaa16f953 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Thu, 11 Sep 2025 23:48:05 +0800 Subject: [PATCH 07/30] dockerfile fix --- dockerfiles/Dockerfile.dev.os | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dockerfiles/Dockerfile.dev.os b/dockerfiles/Dockerfile.dev.os index a544e94af..a7fc113d6 100644 --- a/dockerfiles/Dockerfile.dev.os +++ b/dockerfiles/Dockerfile.dev.os @@ -4,11 +4,10 @@ FROM python:3.10-slim # update apt pkgs, and install build-essential for ciso8601 RUN apt-get update && \ apt-get -y upgrade && \ - apt-get -y install build-essential && \ + apt-get -y install build-essential git && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* -RUN apt-get -y install git # update certs used by Requests ENV CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt From 5adc1eb3a752bddd0703f4560fe0bbff2fe3a231 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Thu, 11 Sep 2025 23:48:42 +0800 Subject: [PATCH 08/30] add tests --- stac_fastapi/tests/api/test_api.py | 56 ++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/stac_fastapi/tests/api/test_api.py b/stac_fastapi/tests/api/test_api.py index c07efbd2e..42173e1e5 100644 --- a/stac_fastapi/tests/api/test_api.py +++ b/stac_fastapi/tests/api/test_api.py @@ -1540,3 +1540,59 @@ async def test_collection_items_limit_env_variable( assert resp.status_code == 200 resp_json = resp.json() assert int(limit) == len(resp_json["features"]) + + +@pytest.mark.asyncio +async def test_collection_items_sort_desc(app_client, txn_client, ctx): + """Verify GET /collections/{collectionId}/items honors descending sort on properties.datetime.""" + first_item = ctx.item + + # Create a second item in the same collection with an earlier datetime + second_item = dict(first_item) + second_item["id"] = "another-item-for-collection-sort-desc" + another_item_date = datetime.strptime( + first_item["properties"]["datetime"], "%Y-%m-%dT%H:%M:%SZ" + ) - timedelta(days=1) + second_item["properties"]["datetime"] = another_item_date.strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + + await create_item(txn_client, second_item) + + # Descending sort: the original (newer) item should come first + resp = await app_client.get( + f"/collections/{first_item['collection']}/items", + params=[("sortby", "-properties.datetime")], + ) + assert resp.status_code == 200 + resp_json = resp.json() + assert resp_json["features"][0]["id"] == first_item["id"] + assert resp_json["features"][1]["id"] == second_item["id"] + + +@pytest.mark.asyncio +async def test_collection_items_sort_asc(app_client, txn_client, ctx): + """Verify GET /collections/{collectionId}/items honors ascending sort on properties.datetime.""" + first_item = ctx.item + + # Create a second item in the same collection with an earlier datetime + second_item = dict(first_item) + second_item["id"] = "another-item-for-collection-sort-asc" + another_item_date = datetime.strptime( + first_item["properties"]["datetime"], "%Y-%m-%dT%H:%M:%SZ" + ) - timedelta(days=1) + second_item["properties"]["datetime"] = another_item_date.strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + + await create_item(txn_client, second_item) + + # Ascending sort: the older item should come first + resp = await app_client.get( + f"/collections/{first_item['collection']}/items", + params=[("sortby", "+properties.datetime")], + ) + assert resp.status_code == 200 + resp_json = resp.json() + assert resp_json["features"][0]["id"] == second_item["id"] + assert resp_json["features"][1]["id"] == first_item["id"] From d19c85543a0ae2f2cabfbed2189febaa65bf26d3 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Thu, 11 Sep 2025 23:48:59 +0800 Subject: [PATCH 09/30] missing imports --- .../opensearch/stac_fastapi/opensearch/app.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py index 1fa036d1b..e0b157276 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py @@ -7,7 +7,12 @@ from fastapi import FastAPI from stac_fastapi.api.app import StacApi -from stac_fastapi.api.models import create_get_request_model, create_post_request_model +from stac_fastapi.api.models import ( + ItemCollectionUri, + create_get_request_model, + create_post_request_model, + create_request_model, +) from stac_fastapi.core.core import ( BulkTransactionsClient, CoreClient, @@ -33,6 +38,7 @@ TransactionExtension, ) from stac_fastapi.extensions.core.filter import FilterConformanceClasses +from stac_fastapi.extensions.core.sort import SortConformanceClasses from stac_fastapi.extensions.third_party import BulkTransactionExtension from stac_fastapi.opensearch.config import OpensearchSettings from stac_fastapi.opensearch.database_logic import ( @@ -115,6 +121,17 @@ post_request_model = create_post_request_model(search_extensions) +items_get_request_model = create_request_model( + model_name="ItemCollectionUri", + base_model=ItemCollectionUri, + extensions=[ + SortExtension( + conformance_classes=[SortConformanceClasses.ITEMS], + ) + ], + request_type="GET", +) + app_config = { "title": os.getenv("STAC_FASTAPI_TITLE", "stac-fastapi-opensearch"), "description": os.getenv("STAC_FASTAPI_DESCRIPTION", "stac-fastapi-opensearch"), @@ -129,6 +146,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, "route_dependencies": get_route_dependencies(), } From 8c6742f6778cfe5b7fcc478373420453e4c890ba Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Fri, 12 Sep 2025 00:22:02 +0800 Subject: [PATCH 10/30] pass item collection to get search --- stac_fastapi/core/stac_fastapi/core/core.py | 69 ++------------------- 1 file changed, 6 insertions(+), 63 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index 5a16920d5..52c60f689 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -5,7 +5,6 @@ from datetime import datetime as datetime_type from datetime import timezone from enum import Enum -from types import SimpleNamespace from typing import List, Optional, Set, Type, Union from urllib.parse import unquote_plus, urljoin @@ -311,71 +310,15 @@ async def item_collection( Exception: If any error occurs while reading the items from the database. """ request: Request = kwargs["request"] - token = request.query_params.get("token") - - base_url = str(request.base_url) - - es_sort = None - if sortby: - specs = [] - for s in sortby: - field = s[1:] - direction = "desc" if s[0] == "-" else "asc" - specs.append(SimpleNamespace(field=field, direction=direction)) - if specs: - es_sort = self.database.populate_sort(specs) - - collection = await self.get_collection( - collection_id=collection_id, request=request - ) - collection_id = collection.get("id") - if collection_id is None: - raise HTTPException(status_code=404, detail="Collection not found") - - search = self.database.make_search() - search = self.database.apply_collections_filter( - search=search, collection_ids=[collection_id] - ) - - try: - search, datetime_search = self.database.apply_datetime_filter( - search=search, datetime=datetime - ) - except (ValueError, TypeError) as e: - # Handle invalid interval formats if return_date fails - msg = f"Invalid interval format: {datetime}, error: {e}" - logger.error(msg) - raise HTTPException(status_code=400, detail=msg) - - if bbox: - bbox = [float(x) for x in bbox] - if len(bbox) == 6: - bbox = [bbox[0], bbox[1], bbox[3], bbox[4]] - search = self.database.apply_bbox_filter(search=search, bbox=bbox) - - limit = int(request.query_params.get("limit", os.getenv("STAC_ITEM_LIMIT", 10))) - items, maybe_count, next_token = await self.database.execute_search( - search=search, + return await self.get_search( + request=request, + collections=[collection_id], + bbox=bbox, + datetime=datetime, limit=limit, - sort=es_sort, token=token, - collection_ids=[collection_id], - datetime_search=datetime_search, - ) - - items = [ - self.item_serializer.db_to_stac(item, base_url=base_url) for item in items - ] - - 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, + sortby=sortby, ) async def get_item( From 690549e3f071a6c612bc58eef3e875503244d060 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Fri, 12 Sep 2025 01:02:28 +0800 Subject: [PATCH 11/30] update --- stac_fastapi/core/stac_fastapi/core/core.py | 24 ++++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index 52c60f689..e6bff70a6 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -310,7 +310,13 @@ async def item_collection( Exception: If any error occurs while reading the items from the database. """ request: Request = kwargs["request"] + # Ensure the collection exists; otherwise return 404 to match API contract + try: + await self.get_collection(collection_id=collection_id, request=request) + except Exception: + raise HTTPException(status_code=404, detail="Collection not found") + # Delegate directly to GET search for consistency return await self.get_search( request=request, collections=[collection_id], @@ -481,13 +487,15 @@ async def post_search( search = self.database.apply_bbox_filter(search=search, bbox=bbox) - if search_request.intersects: + if hasattr(search_request, "intersects") and getattr( + search_request, "intersects" + ): search = self.database.apply_intersects_filter( - search=search, intersects=search_request.intersects + search=search, intersects=getattr(search_request, "intersects") ) - if search_request.query: - for field_name, expr in search_request.query.items(): + if hasattr(search_request, "query") and getattr(search_request, "query"): + for field_name, expr in getattr(search_request, "query").items(): field = "properties__" + field_name for op, value in expr.items(): # Convert enum to string @@ -516,8 +524,8 @@ async def post_search( ) sort = None - if search_request.sortby: - sort = self.database.populate_sort(search_request.sortby) + if hasattr(search_request, "sortby") and getattr(search_request, "sortby"): + sort = self.database.populate_sort(getattr(search_request, "sortby")) limit = 10 if search_request.limit: @@ -526,9 +534,9 @@ async def post_search( items, maybe_count, next_token = await self.database.execute_search( search=search, limit=limit, - token=search_request.token, + token=getattr(search_request, "token", None), sort=sort, - collection_ids=search_request.collections, + collection_ids=getattr(search_request, "collections", None), datetime_search=datetime_search, ) From 09c1eb28b8d009bd48c8852e66a80bbdf686ea75 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Fri, 12 Sep 2025 01:23:57 +0800 Subject: [PATCH 12/30] support pagination --- stac_fastapi/core/stac_fastapi/core/core.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index e6bff70a6..b40acfed7 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -390,12 +390,14 @@ async def get_search( HTTPException: If any error occurs while searching the catalog. """ limit = int(request.query_params.get("limit", os.getenv("STAC_ITEM_LIMIT", 10))) + # Support pagination when called from item_collection by reading token from the query string + token_val = token or request.query_params.get("token") base_args = { "collections": collections, "ids": ids, "bbox": bbox, "limit": limit, - "token": token, + "token": token_val, "query": orjson.loads(query) if query else query, "q": q, } @@ -531,10 +533,14 @@ async def post_search( if search_request.limit: limit = search_request.limit + # Use token from the request if the model doesn't define it + token_param = getattr( + search_request, "token", None + ) or request.query_params.get("token") items, maybe_count, next_token = await self.database.execute_search( search=search, limit=limit, - token=getattr(search_request, "token", None), + token=token_param, sort=sort, collection_ids=getattr(search_request, "collections", None), datetime_search=datetime_search, From 25e224116456944cca42f6a72bbdbeb51d0bd014 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Fri, 12 Sep 2025 11:31:24 +0800 Subject: [PATCH 13/30] update changelog --- CHANGELOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff60dc050..dfc2cd5ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,15 +10,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added -- Sortby functionality to the item collection route. +- Sortby functionality to the item collection route. [#437](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/437) ### Changed -- 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) +- 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) +- 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) ### Fixed -- 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. +- 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) ## [v6.2.1] - 2025-09-02 From b1c64a44260f8a8294bcee9546d56cf0b01f72b1 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Fri, 12 Sep 2025 11:31:54 +0800 Subject: [PATCH 14/30] fix default sort --- stac_fastapi/core/stac_fastapi/core/core.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index b40acfed7..f6d205ae6 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -310,7 +310,7 @@ async def item_collection( Exception: If any error occurs while reading the items from the database. """ request: Request = kwargs["request"] - # Ensure the collection exists; otherwise return 404 to match API contract + try: await self.get_collection(collection_id=collection_id, request=request) except Exception: @@ -409,10 +409,18 @@ async def get_search( base_args["intersects"] = orjson.loads(unquote_plus(intersects)) if sortby: - base_args["sortby"] = [ - {"field": sort[1:], "direction": "desc" if sort[0] == "-" else "asc"} - for sort in 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: + base_args["sortby"] = parsed_sort if filter_expr: base_args["filter_lang"] = "cql2-json" From 483466fe048a916e1f511195f49b2fe6b4fd92a0 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Fri, 12 Sep 2025 11:49:58 +0800 Subject: [PATCH 15/30] test default sort --- stac_fastapi/tests/api/test_api.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/stac_fastapi/tests/api/test_api.py b/stac_fastapi/tests/api/test_api.py index 42173e1e5..1fa979b88 100644 --- a/stac_fastapi/tests/api/test_api.py +++ b/stac_fastapi/tests/api/test_api.py @@ -1596,3 +1596,13 @@ async def test_collection_items_sort_asc(app_client, txn_client, ctx): resp_json = resp.json() assert resp_json["features"][0]["id"] == second_item["id"] assert resp_json["features"][1]["id"] == first_item["id"] + + # Also verify bare field (no +) sorts ascending by default + resp = await app_client.get( + f"/collections/{first_item['collection']}/items", + params=[("sortby", "properties.datetime")], + ) + assert resp.status_code == 200 + resp_json = resp.json() + assert resp_json["features"][0]["id"] == second_item["id"] + assert resp_json["features"][1]["id"] == first_item["id"] From 56e97a87d5e0c0785e5d03b4ea935d4b62db5781 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Fri, 12 Sep 2025 12:43:01 +0800 Subject: [PATCH 16/30] update docstring --- stac_fastapi/core/stac_fastapi/core/core.py | 27 ++++++++++++--------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index f6d205ae6..004d0c21c 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -291,23 +291,28 @@ async def item_collection( token: Optional[str] = None, **kwargs, ) -> stac_types.ItemCollection: - """Read items from a specific collection in the database. + """List items within a specific collection. + + This endpoint delegates to ``get_search`` under the hood with + ``collections=[collection_id]`` so that filtering, sorting and pagination + behave identically to the Search endpoints. Args: - collection_id (str): The identifier of the collection to read items from. - bbox (Optional[BBox]): The bounding box to filter items by. - datetime (Optional[str]): The datetime range to filter items by. - sortby (Optional[str]]): Sort spec like "-datetime". Bare fields imply ascending. - token (str): A token used for pagination. - request (Request): The incoming request. + collection_id (str): ID of the collection to list items from. + bbox (Optional[BBox]): Optional bounding box filter. + datetime (Optional[str]): Optional datetime or interval filter. + limit (Optional[int]): Optional page size. Defaults to env ``STAC_ITEM_LIMIT`` when unset. + sortby (Optional[str]): Optional sort specification. Accepts repeated values + like ``sortby=-properties.datetime`` or ``sortby=+id``. Bare fields (e.g. ``sortby=id``) + imply ascending order. + token (Optional[str]): Optional pagination token. + **kwargs: Must include ``request`` (FastAPI Request). Returns: - ItemCollection: An `ItemCollection` object containing the items from the specified collection that meet - the filter criteria and links to various resources. + ItemCollection: Feature collection with items, paging links, and counts. Raises: - HTTPException: If the specified collection is not found. - Exception: If any error occurs while reading the items from the database. + HTTPException: 404 if the collection does not exist. """ request: Request = kwargs["request"] From 7963b8c1adb80fecfce5900c18a86ebba5925278 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Fri, 12 Sep 2025 12:52:54 +0800 Subject: [PATCH 17/30] remove debug print --- .../sfeos_helpers/stac_fastapi/sfeos_helpers/database/query.py | 1 - 1 file changed, 1 deletion(-) diff --git a/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database/query.py b/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database/query.py index 84578fbf7..80d071287 100644 --- a/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database/query.py +++ b/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database/query.py @@ -82,7 +82,6 @@ def populate_sort_shared(sortby: List) -> Optional[Dict[str, Dict[str, str]]]: directly used in search requests. Always includes 'id' as secondary sort to ensure unique pagination tokens. """ - print("sortbx: ", sortby) if sortby: sort_config = {s.field: {"order": s.direction} for s in sortby} sort_config.setdefault("id", {"order": "asc"}) From 77dfa21a2d32cba20f6a15c5c9784a4e660e9e6a Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Fri, 12 Sep 2025 15:45:23 +0800 Subject: [PATCH 18/30] remove request token --- stac_fastapi/core/stac_fastapi/core/core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index 004d0c21c..219067593 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -395,14 +395,14 @@ async def get_search( HTTPException: If any error occurs while searching the catalog. """ limit = int(request.query_params.get("limit", os.getenv("STAC_ITEM_LIMIT", 10))) - # Support pagination when called from item_collection by reading token from the query string - token_val = token or request.query_params.get("token") + # # Support pagination when called from item_collection by reading token from the query string + # token_val = token or request.query_params.get("token") base_args = { "collections": collections, "ids": ids, "bbox": bbox, "limit": limit, - "token": token_val, + "token": token, "query": orjson.loads(query) if query else query, "q": q, } From db0e448b2ce22cf5c44bd23873eeb93def6add47 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Fri, 12 Sep 2025 15:53:38 +0800 Subject: [PATCH 19/30] clean up --- stac_fastapi/core/stac_fastapi/core/core.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index 219067593..228565d90 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -395,8 +395,7 @@ async def get_search( HTTPException: If any error occurs while searching the catalog. """ limit = int(request.query_params.get("limit", os.getenv("STAC_ITEM_LIMIT", 10))) - # # Support pagination when called from item_collection by reading token from the query string - # token_val = token or request.query_params.get("token") + base_args = { "collections": collections, "ids": ids, From 9d66d59b89418927012a6458561ed92867590804 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Fri, 12 Sep 2025 16:26:45 +0800 Subject: [PATCH 20/30] add query to item collection --- stac_fastapi/core/stac_fastapi/core/core.py | 3 ++ .../stac_fastapi/elasticsearch/app.py | 6 +++- .../opensearch/stac_fastapi/opensearch/app.py | 6 +++- stac_fastapi/tests/api/test_api.py | 28 +++++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index 228565d90..36481c57e 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -289,6 +289,7 @@ async def item_collection( limit: Optional[int] = None, sortby: Optional[str] = None, token: Optional[str] = None, + query: Optional[str] = None, **kwargs, ) -> stac_types.ItemCollection: """List items within a specific collection. @@ -306,6 +307,7 @@ async def item_collection( like ``sortby=-properties.datetime`` or ``sortby=+id``. Bare fields (e.g. ``sortby=id``) imply ascending order. token (Optional[str]): Optional pagination token. + query (Optional[str]): Optional query string. **kwargs: Must include ``request`` (FastAPI Request). Returns: @@ -330,6 +332,7 @@ async def item_collection( limit=limit, token=token, sortby=sortby, + query=query, ) async def get_item( diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py index 7ee4698e6..135414d96 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py @@ -44,6 +44,7 @@ TransactionExtension, ) from stac_fastapi.extensions.core.filter import FilterConformanceClasses +from stac_fastapi.extensions.core.query import QueryConformanceClasses from stac_fastapi.extensions.core.sort import SortConformanceClasses from stac_fastapi.extensions.third_party import BulkTransactionExtension from stac_fastapi.sfeos_helpers.aggregation import EsAsyncBaseAggregationClient @@ -127,7 +128,10 @@ extensions=[ SortExtension( conformance_classes=[SortConformanceClasses.ITEMS], - ) + ), + QueryExtension( + conformance_classes=[QueryConformanceClasses.ITEMS], + ), ], request_type="GET", ) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py index e0b157276..3ab5d1d39 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py @@ -38,6 +38,7 @@ TransactionExtension, ) from stac_fastapi.extensions.core.filter import FilterConformanceClasses +from stac_fastapi.extensions.core.query import QueryConformanceClasses from stac_fastapi.extensions.core.sort import SortConformanceClasses from stac_fastapi.extensions.third_party import BulkTransactionExtension from stac_fastapi.opensearch.config import OpensearchSettings @@ -127,7 +128,10 @@ extensions=[ SortExtension( conformance_classes=[SortConformanceClasses.ITEMS], - ) + ), + QueryExtension( + conformance_classes=[QueryConformanceClasses.ITEMS], + ), ], request_type="GET", ) diff --git a/stac_fastapi/tests/api/test_api.py b/stac_fastapi/tests/api/test_api.py index 1fa979b88..80995a89d 100644 --- a/stac_fastapi/tests/api/test_api.py +++ b/stac_fastapi/tests/api/test_api.py @@ -1606,3 +1606,31 @@ async def test_collection_items_sort_asc(app_client, txn_client, ctx): resp_json = resp.json() assert resp_json["features"][0]["id"] == second_item["id"] assert resp_json["features"][1]["id"] == first_item["id"] + + +@pytest.mark.asyncio +async def test_item_collection_query(app_client, txn_client, ctx): + """Simple query parameter test on the Item Collection route. + + Creates an item with a unique property and ensures it can be retrieved + using the 'query' parameter on GET /collections/{collection_id}/items. + """ + unique_val = str(uuid.uuid4()) + test_item = deepcopy(ctx.item) + test_item["id"] = f"query-basic-{unique_val}" + # Add a property to filter on + test_item.setdefault("properties", {})["test_query_key"] = unique_val + + await create_item(txn_client, test_item) + + # Provide the query parameter as a JSON string without adding new imports + query_param = f'{{"test_query_key": {{"eq": "{unique_val}"}}}}' + + resp = await app_client.get( + f"/collections/{test_item['collection']}/items", + params=[("query", query_param)], + ) + assert resp.status_code == 200 + resp_json = resp.json() + ids = [f["id"] for f in resp_json["features"]] + assert test_item["id"] in ids From 2588f4e33ee08798b518442d1e5a0a901b41fad7 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 13 Sep 2025 10:51:20 +0800 Subject: [PATCH 21/30] add filter to item collection --- CHANGELOG.md | 4 +- stac_fastapi/core/stac_fastapi/core/core.py | 18 ++++-- .../stac_fastapi/elasticsearch/app.py | 1 + stac_fastapi/tests/api/test_api.py | 55 +++++++++++++++++ stac_fastapi/tests/api/test_filters.py | 59 +++++++++++++++++++ 5 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 stac_fastapi/tests/api/test_filters.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dfc2cd5ca..f59771dec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added -- Sortby functionality to the item collection route. [#437](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/437) +- Sort extension and sortby functionality to the item collection route. [#437](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/437) +- Query extension and query functionality to the item collection route. [#437](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/437) +- Filter extension and filter functionality to the item collection route. [#437](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/437) ### Changed diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index 36481c57e..f1d094753 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -284,10 +284,13 @@ async def get_collection( async def item_collection( self, collection_id: str, + request: Request, bbox: Optional[BBox] = None, datetime: Optional[str] = None, limit: Optional[int] = None, sortby: Optional[str] = None, + filter_expr: Optional[str] = None, + filter_lang: Optional[str] = None, token: Optional[str] = None, query: Optional[str] = None, **kwargs, @@ -300,6 +303,7 @@ async def item_collection( Args: collection_id (str): ID of the collection to list items from. + request (Request): FastAPI Request object. bbox (Optional[BBox]): Optional bounding box filter. datetime (Optional[str]): Optional datetime or interval filter. limit (Optional[int]): Optional page size. Defaults to env ``STAC_ITEM_LIMIT`` when unset. @@ -308,7 +312,8 @@ async def item_collection( imply ascending order. token (Optional[str]): Optional pagination token. query (Optional[str]): Optional query string. - **kwargs: Must include ``request`` (FastAPI Request). + filter_expr (Optional[str]): Optional filter expression. + filter_lang (Optional[str]): Optional filter language. Returns: ItemCollection: Feature collection with items, paging links, and counts. @@ -316,8 +321,6 @@ async def item_collection( Raises: HTTPException: 404 if the collection does not exist. """ - request: Request = kwargs["request"] - try: await self.get_collection(collection_id=collection_id, request=request) except Exception: @@ -333,6 +336,8 @@ async def item_collection( token=token, sortby=sortby, query=query, + filter_expr=filter_expr, + filter_lang=filter_lang, ) async def get_item( @@ -521,9 +526,14 @@ async def post_search( search=search, op=operator, field=field, value=value ) - # only cql2_json is supported here + # Apply CQL2 filter (support both 'filter_expr' and canonical 'filter') + cql2_filter = None if hasattr(search_request, "filter_expr"): cql2_filter = getattr(search_request, "filter_expr", None) + if cql2_filter is None and hasattr(search_request, "filter"): + cql2_filter = getattr(search_request, "filter", None) + + if cql2_filter is not None: try: search = await self.database.apply_cql2_filter(search, cql2_filter) except Exception as e: diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py index 135414d96..34c582c19 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py @@ -132,6 +132,7 @@ QueryExtension( conformance_classes=[QueryConformanceClasses.ITEMS], ), + filter_extension, ], request_type="GET", ) diff --git a/stac_fastapi/tests/api/test_api.py b/stac_fastapi/tests/api/test_api.py index 80995a89d..2ef6b9a40 100644 --- a/stac_fastapi/tests/api/test_api.py +++ b/stac_fastapi/tests/api/test_api.py @@ -1,3 +1,4 @@ +import json import os import random import uuid @@ -1634,3 +1635,57 @@ async def test_item_collection_query(app_client, txn_client, ctx): resp_json = resp.json() ids = [f["id"] for f in resp_json["features"]] assert test_item["id"] in ids + + +@pytest.mark.asyncio +async def test_filter_by_id(app_client, ctx): + """Test filtering items by ID using the filter parameter.""" + # Get the test item and collection from the context + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + + # Create a filter to match the item by ID + filter_body = {"op": "=", "args": [{"property": "id"}, item_id]} + + # Make the request with the filter + params = [("filter", json.dumps(filter_body)), ("filter-lang", "cql2-json")] + + resp = await app_client.get( + f"/collections/{collection_id}/items", + params=params, + ) + + # Verify the response + assert resp.status_code == 200 + resp_json = resp.json() + + # Should find exactly one matching item + assert len(resp_json["features"]) == 1 + assert resp_json["features"][0]["id"] == item_id + assert resp_json["features"][0]["collection"] == collection_id + + +@pytest.mark.asyncio +async def test_filter_by_nonexistent_id(app_client, ctx): + """Test filtering with a non-existent ID returns no results.""" + collection_id = ctx.item["collection"] + + # Create a filter with a non-existent ID + filter_body = { + "op": "=", + "args": [{"property": "id"}, "this-id-does-not-exist-12345"], + } + + # Make the request with the filter + params = [("filter", json.dumps(filter_body)), ("filter-lang", "cql2-json")] + + resp = await app_client.get( + f"/collections/{collection_id}/items", + params=params, + ) + + # Verify the response + assert resp.status_code == 200 + resp_json = resp.json() + assert len(resp_json["features"]) == 0 diff --git a/stac_fastapi/tests/api/test_filters.py b/stac_fastapi/tests/api/test_filters.py new file mode 100644 index 000000000..bc8edfde7 --- /dev/null +++ b/stac_fastapi/tests/api/test_filters.py @@ -0,0 +1,59 @@ +"""Tests for STAC API filter extension.""" + +import json + +import pytest + + +@pytest.mark.asyncio +async def test_filter_by_id(app_client, ctx): + """Test filtering items by ID using the filter parameter.""" + # Get the test item and collection from the context + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + + # Create a filter to match the item by ID + filter_body = {"op": "=", "args": [{"property": "id"}, item_id]} + + # Make the request with the filter + params = [("filter", json.dumps(filter_body)), ("filter-lang", "cql2-json")] + + resp = await app_client.get( + f"/collections/{collection_id}/items", + params=params, + ) + + # Verify the response + assert resp.status_code == 200 + resp_json = resp.json() + + # Should find exactly one matching item + assert len(resp_json["features"]) == 1 + assert resp_json["features"][0]["id"] == item_id + assert resp_json["features"][0]["collection"] == collection_id + + +@pytest.mark.asyncio +async def test_filter_by_nonexistent_id(app_client, ctx): + """Test filtering with a non-existent ID returns no results.""" + collection_id = ctx.item["collection"] + + # Create a filter with a non-existent ID + filter_body = { + "op": "=", + "args": [{"property": "id"}, "this-id-does-not-exist-12345"], + } + + # Make the request with the filter + params = [("filter", json.dumps(filter_body)), ("filter-lang", "cql2-json")] + + resp = await app_client.get( + f"/collections/{collection_id}/items", + params=params, + ) + + # Verify the response + assert resp.status_code == 200 + resp_json = resp.json() + assert len(resp_json["features"]) == 0 From 852225c91c0b208c346171710b7922a04f152e00 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 13 Sep 2025 11:01:22 +0800 Subject: [PATCH 22/30] remove unused test file --- stac_fastapi/tests/api/test_filters.py | 59 -------------------------- 1 file changed, 59 deletions(-) delete mode 100644 stac_fastapi/tests/api/test_filters.py diff --git a/stac_fastapi/tests/api/test_filters.py b/stac_fastapi/tests/api/test_filters.py deleted file mode 100644 index bc8edfde7..000000000 --- a/stac_fastapi/tests/api/test_filters.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Tests for STAC API filter extension.""" - -import json - -import pytest - - -@pytest.mark.asyncio -async def test_filter_by_id(app_client, ctx): - """Test filtering items by ID using the filter parameter.""" - # Get the test item and collection from the context - item = ctx.item - collection_id = item["collection"] - item_id = item["id"] - - # Create a filter to match the item by ID - filter_body = {"op": "=", "args": [{"property": "id"}, item_id]} - - # Make the request with the filter - params = [("filter", json.dumps(filter_body)), ("filter-lang", "cql2-json")] - - resp = await app_client.get( - f"/collections/{collection_id}/items", - params=params, - ) - - # Verify the response - assert resp.status_code == 200 - resp_json = resp.json() - - # Should find exactly one matching item - assert len(resp_json["features"]) == 1 - assert resp_json["features"][0]["id"] == item_id - assert resp_json["features"][0]["collection"] == collection_id - - -@pytest.mark.asyncio -async def test_filter_by_nonexistent_id(app_client, ctx): - """Test filtering with a non-existent ID returns no results.""" - collection_id = ctx.item["collection"] - - # Create a filter with a non-existent ID - filter_body = { - "op": "=", - "args": [{"property": "id"}, "this-id-does-not-exist-12345"], - } - - # Make the request with the filter - params = [("filter", json.dumps(filter_body)), ("filter-lang", "cql2-json")] - - resp = await app_client.get( - f"/collections/{collection_id}/items", - params=params, - ) - - # Verify the response - assert resp.status_code == 200 - resp_json = resp.json() - assert len(resp_json["features"]) == 0 From 1249c7a884943df7915c76d41c5b01a0d68080cc Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 13 Sep 2025 12:55:30 +0800 Subject: [PATCH 23/30] fix test --- stac_fastapi/tests/api/test_api.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/stac_fastapi/tests/api/test_api.py b/stac_fastapi/tests/api/test_api.py index 2ef6b9a40..220c84ec6 100644 --- a/stac_fastapi/tests/api/test_api.py +++ b/stac_fastapi/tests/api/test_api.py @@ -1667,15 +1667,21 @@ async def test_filter_by_id(app_client, ctx): @pytest.mark.asyncio -async def test_filter_by_nonexistent_id(app_client, ctx): +async def test_filter_by_nonexistent_id(app_client, ctx, txn_client): """Test filtering with a non-existent ID returns no results.""" - collection_id = ctx.item["collection"] + # Get the test collection and item from context + collection_id = ctx.collection["id"] + item_id = ctx.item["id"] - # Create a filter with a non-existent ID - filter_body = { - "op": "=", - "args": [{"property": "id"}, "this-id-does-not-exist-12345"], - } + # First, verify the item exists + resp = await app_client.get(f"/collections/{collection_id}/items/{item_id}") + assert resp.status_code == 200 + + # Create a non-existent ID + non_existent_id = f"non-existent-{str(uuid.uuid4())}" + + # Create a filter with the non-existent ID using CQL2-JSON syntax + filter_body = {"op": "=", "args": [{"property": "id"}, non_existent_id]} # Make the request with the filter params = [("filter", json.dumps(filter_body)), ("filter-lang", "cql2-json")] @@ -1688,4 +1694,6 @@ async def test_filter_by_nonexistent_id(app_client, ctx): # Verify the response assert resp.status_code == 200 resp_json = resp.json() - assert len(resp_json["features"]) == 0 + assert ( + len(resp_json["features"]) == 0 + ), f"Expected no items with ID {non_existent_id}, but found {len(resp_json['features'])} matches" From e10baad8d648df40c72b4f0f5e19a198d4121cb4 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 13 Sep 2025 13:20:21 +0800 Subject: [PATCH 24/30] passing locally --- stac_fastapi/tests/api/test_api.py | 33 ++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/stac_fastapi/tests/api/test_api.py b/stac_fastapi/tests/api/test_api.py index 220c84ec6..90dd34edd 100644 --- a/stac_fastapi/tests/api/test_api.py +++ b/stac_fastapi/tests/api/test_api.py @@ -1665,6 +1665,26 @@ async def test_filter_by_id(app_client, ctx): assert resp_json["features"][0]["id"] == item_id assert resp_json["features"][0]["collection"] == collection_id + # Create a non-existent ID + non_existent_id = f"non-existent-{str(uuid.uuid4())}" + + filter_body = {"op": "=", "args": [{"property": "id"}, non_existent_id]} + + # Make the request with the filter + params = [("filter", json.dumps(filter_body)), ("filter-lang", "cql2-json")] + + resp = await app_client.get( + f"/collections/{collection_id}/items", + params=params, + ) + + # Verify the response + assert resp.status_code == 200 + resp_json = resp.json() + + # Should find exactly one matching item + assert len(resp_json["features"]) == 0 + @pytest.mark.asyncio async def test_filter_by_nonexistent_id(app_client, ctx, txn_client): @@ -1683,13 +1703,14 @@ async def test_filter_by_nonexistent_id(app_client, ctx, txn_client): # Create a filter with the non-existent ID using CQL2-JSON syntax filter_body = {"op": "=", "args": [{"property": "id"}, non_existent_id]} - # Make the request with the filter - params = [("filter", json.dumps(filter_body)), ("filter-lang", "cql2-json")] + # URL-encode the filter JSON + import urllib.parse - resp = await app_client.get( - f"/collections/{collection_id}/items", - params=params, - ) + encoded_filter = urllib.parse.quote(json.dumps(filter_body)) + + # Make the request with URL-encoded filter in the query string + url = f"/collections/{collection_id}/items?filter-lang=cql2-json&filter={encoded_filter}" + resp = await app_client.get(url) # Verify the response assert resp.status_code == 200 From 3b3b0875f4b88b9e935762efc67bec2b4bc8c837 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 13 Sep 2025 13:38:07 +0800 Subject: [PATCH 25/30] add filter extension to os --- .../opensearch/stac_fastapi/opensearch/app.py | 1 + stac_fastapi/tests/api/test_api.py | 20 ------------------- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py index 3ab5d1d39..26b4bc0ca 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py @@ -132,6 +132,7 @@ QueryExtension( conformance_classes=[QueryConformanceClasses.ITEMS], ), + filter_extension, ], request_type="GET", ) diff --git a/stac_fastapi/tests/api/test_api.py b/stac_fastapi/tests/api/test_api.py index 90dd34edd..95a60781e 100644 --- a/stac_fastapi/tests/api/test_api.py +++ b/stac_fastapi/tests/api/test_api.py @@ -1665,26 +1665,6 @@ async def test_filter_by_id(app_client, ctx): assert resp_json["features"][0]["id"] == item_id assert resp_json["features"][0]["collection"] == collection_id - # Create a non-existent ID - non_existent_id = f"non-existent-{str(uuid.uuid4())}" - - filter_body = {"op": "=", "args": [{"property": "id"}, non_existent_id]} - - # Make the request with the filter - params = [("filter", json.dumps(filter_body)), ("filter-lang", "cql2-json")] - - resp = await app_client.get( - f"/collections/{collection_id}/items", - params=params, - ) - - # Verify the response - assert resp.status_code == 200 - resp_json = resp.json() - - # Should find exactly one matching item - assert len(resp_json["features"]) == 0 - @pytest.mark.asyncio async def test_filter_by_nonexistent_id(app_client, ctx, txn_client): From 62e8d303731297c052ce118cd0ce38ac3502ed97 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 13 Sep 2025 15:52:08 +0800 Subject: [PATCH 26/30] lint --- stac_fastapi/core/stac_fastapi/core/core.py | 2 +- stac_fastapi/tests/api/test_api.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index 7c351fb89..7b4502669 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -911,7 +911,7 @@ async def delete_collection(self, collection_id: str, **kwargs) -> None: @attr.s class BulkTransactionsClient(BaseBulkTransactionsClient): - """A client for posting bulk transactions to a Postgres database. + """A client for posting bulk transactions. Attributes: session: An instance of `Session` to use for database connection. diff --git a/stac_fastapi/tests/api/test_api.py b/stac_fastapi/tests/api/test_api.py index 96a95ada8..843996a0d 100644 --- a/stac_fastapi/tests/api/test_api.py +++ b/stac_fastapi/tests/api/test_api.py @@ -1699,7 +1699,7 @@ async def test_filter_by_nonexistent_id(app_client, ctx, txn_client): len(resp_json["features"]) == 0 ), f"Expected no items with ID {non_existent_id}, but found {len(resp_json['features'])} matches" - + async def test_search_max_item_limit( app_client, load_test_data, txn_client, monkeypatch ): From 7355a80f835a23b11cf29764498c7b2458e76a0a Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 13 Sep 2025 19:29:05 +0800 Subject: [PATCH 27/30] move item collection tests --- stac_fastapi/tests/api/test_api.py | 184 ----------------- .../tests/api/test_api_item_collection.py | 192 ++++++++++++++++++ 2 files changed, 192 insertions(+), 184 deletions(-) create mode 100644 stac_fastapi/tests/api/test_api_item_collection.py diff --git a/stac_fastapi/tests/api/test_api.py b/stac_fastapi/tests/api/test_api.py index 843996a0d..9387505b5 100644 --- a/stac_fastapi/tests/api/test_api.py +++ b/stac_fastapi/tests/api/test_api.py @@ -1,4 +1,3 @@ -import json import os import random import uuid @@ -1517,189 +1516,6 @@ async def test_search_collection_limit_env_variable( assert int(limit) == len(resp_json["features"]) -@pytest.mark.asyncio -async def test_collection_items_limit_env_variable( - app_client, txn_client, load_test_data -): - limit = "5" - os.environ["STAC_ITEM_LIMIT"] = limit - - test_collection = load_test_data("test_collection.json") - test_collection_id = "test-collection-items-limit" - test_collection["id"] = test_collection_id - await create_collection(txn_client, test_collection) - - item = load_test_data("test_item.json") - item["collection"] = test_collection_id - - for i in range(10): - test_item = item.copy() - test_item["id"] = f"test-item-collection-{i}" - await create_item(txn_client, test_item) - - resp = await app_client.get(f"/collections/{test_collection_id}/items") - assert resp.status_code == 200 - resp_json = resp.json() - assert int(limit) == len(resp_json["features"]) - - -@pytest.mark.asyncio -async def test_collection_items_sort_desc(app_client, txn_client, ctx): - """Verify GET /collections/{collectionId}/items honors descending sort on properties.datetime.""" - first_item = ctx.item - - # Create a second item in the same collection with an earlier datetime - second_item = dict(first_item) - second_item["id"] = "another-item-for-collection-sort-desc" - another_item_date = datetime.strptime( - first_item["properties"]["datetime"], "%Y-%m-%dT%H:%M:%SZ" - ) - timedelta(days=1) - second_item["properties"]["datetime"] = another_item_date.strftime( - "%Y-%m-%dT%H:%M:%SZ" - ) - - await create_item(txn_client, second_item) - - # Descending sort: the original (newer) item should come first - resp = await app_client.get( - f"/collections/{first_item['collection']}/items", - params=[("sortby", "-properties.datetime")], - ) - assert resp.status_code == 200 - resp_json = resp.json() - assert resp_json["features"][0]["id"] == first_item["id"] - assert resp_json["features"][1]["id"] == second_item["id"] - - -@pytest.mark.asyncio -async def test_collection_items_sort_asc(app_client, txn_client, ctx): - """Verify GET /collections/{collectionId}/items honors ascending sort on properties.datetime.""" - first_item = ctx.item - - # Create a second item in the same collection with an earlier datetime - second_item = dict(first_item) - second_item["id"] = "another-item-for-collection-sort-asc" - another_item_date = datetime.strptime( - first_item["properties"]["datetime"], "%Y-%m-%dT%H:%M:%SZ" - ) - timedelta(days=1) - second_item["properties"]["datetime"] = another_item_date.strftime( - "%Y-%m-%dT%H:%M:%SZ" - ) - - await create_item(txn_client, second_item) - - # Ascending sort: the older item should come first - resp = await app_client.get( - f"/collections/{first_item['collection']}/items", - params=[("sortby", "+properties.datetime")], - ) - assert resp.status_code == 200 - resp_json = resp.json() - assert resp_json["features"][0]["id"] == second_item["id"] - assert resp_json["features"][1]["id"] == first_item["id"] - - # Also verify bare field (no +) sorts ascending by default - resp = await app_client.get( - f"/collections/{first_item['collection']}/items", - params=[("sortby", "properties.datetime")], - ) - assert resp.status_code == 200 - resp_json = resp.json() - assert resp_json["features"][0]["id"] == second_item["id"] - assert resp_json["features"][1]["id"] == first_item["id"] - - -@pytest.mark.asyncio -async def test_item_collection_query(app_client, txn_client, ctx): - """Simple query parameter test on the Item Collection route. - - Creates an item with a unique property and ensures it can be retrieved - using the 'query' parameter on GET /collections/{collection_id}/items. - """ - unique_val = str(uuid.uuid4()) - test_item = deepcopy(ctx.item) - test_item["id"] = f"query-basic-{unique_val}" - # Add a property to filter on - test_item.setdefault("properties", {})["test_query_key"] = unique_val - - await create_item(txn_client, test_item) - - # Provide the query parameter as a JSON string without adding new imports - query_param = f'{{"test_query_key": {{"eq": "{unique_val}"}}}}' - - resp = await app_client.get( - f"/collections/{test_item['collection']}/items", - params=[("query", query_param)], - ) - assert resp.status_code == 200 - resp_json = resp.json() - ids = [f["id"] for f in resp_json["features"]] - assert test_item["id"] in ids - - -@pytest.mark.asyncio -async def test_filter_by_id(app_client, ctx): - """Test filtering items by ID using the filter parameter.""" - # Get the test item and collection from the context - item = ctx.item - collection_id = item["collection"] - item_id = item["id"] - - # Create a filter to match the item by ID - filter_body = {"op": "=", "args": [{"property": "id"}, item_id]} - - # Make the request with the filter - params = [("filter", json.dumps(filter_body)), ("filter-lang", "cql2-json")] - - resp = await app_client.get( - f"/collections/{collection_id}/items", - params=params, - ) - - # Verify the response - assert resp.status_code == 200 - resp_json = resp.json() - - # Should find exactly one matching item - assert len(resp_json["features"]) == 1 - assert resp_json["features"][0]["id"] == item_id - assert resp_json["features"][0]["collection"] == collection_id - - -@pytest.mark.asyncio -async def test_filter_by_nonexistent_id(app_client, ctx, txn_client): - """Test filtering with a non-existent ID returns no results.""" - # Get the test collection and item from context - collection_id = ctx.collection["id"] - item_id = ctx.item["id"] - - # First, verify the item exists - resp = await app_client.get(f"/collections/{collection_id}/items/{item_id}") - assert resp.status_code == 200 - - # Create a non-existent ID - non_existent_id = f"non-existent-{str(uuid.uuid4())}" - - # Create a filter with the non-existent ID using CQL2-JSON syntax - filter_body = {"op": "=", "args": [{"property": "id"}, non_existent_id]} - - # URL-encode the filter JSON - import urllib.parse - - encoded_filter = urllib.parse.quote(json.dumps(filter_body)) - - # Make the request with URL-encoded filter in the query string - url = f"/collections/{collection_id}/items?filter-lang=cql2-json&filter={encoded_filter}" - resp = await app_client.get(url) - - # Verify the response - assert resp.status_code == 200 - resp_json = resp.json() - assert ( - len(resp_json["features"]) == 0 - ), f"Expected no items with ID {non_existent_id}, but found {len(resp_json['features'])} matches" - - async def test_search_max_item_limit( app_client, load_test_data, txn_client, monkeypatch ): diff --git a/stac_fastapi/tests/api/test_api_item_collection.py b/stac_fastapi/tests/api/test_api_item_collection.py new file mode 100644 index 000000000..7c7b51b6f --- /dev/null +++ b/stac_fastapi/tests/api/test_api_item_collection.py @@ -0,0 +1,192 @@ +import json +import os +import uuid +from copy import deepcopy +from datetime import datetime, timedelta + +import pytest + +from ..conftest import create_collection, create_item + + +@pytest.mark.asyncio +async def test_item_collection_limit_env_variable( + app_client, txn_client, load_test_data +): + limit = "5" + os.environ["STAC_ITEM_LIMIT"] = limit + + test_collection = load_test_data("test_collection.json") + test_collection_id = "test-collection-items-limit" + test_collection["id"] = test_collection_id + await create_collection(txn_client, test_collection) + + item = load_test_data("test_item.json") + item["collection"] = test_collection_id + + for i in range(10): + test_item = item.copy() + test_item["id"] = f"test-item-collection-{i}" + await create_item(txn_client, test_item) + + resp = await app_client.get(f"/collections/{test_collection_id}/items") + assert resp.status_code == 200 + resp_json = resp.json() + assert int(limit) == len(resp_json["features"]) + + +@pytest.mark.asyncio +async def test_item_collection_sort_desc(app_client, txn_client, ctx): + """Verify GET /collections/{collectionId}/items honors descending sort on properties.datetime.""" + first_item = ctx.item + + # Create a second item in the same collection with an earlier datetime + second_item = dict(first_item) + second_item["id"] = "another-item-for-collection-sort-desc" + another_item_date = datetime.strptime( + first_item["properties"]["datetime"], "%Y-%m-%dT%H:%M:%SZ" + ) - timedelta(days=1) + second_item["properties"]["datetime"] = another_item_date.strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + + await create_item(txn_client, second_item) + + # Descending sort: the original (newer) item should come first + resp = await app_client.get( + f"/collections/{first_item['collection']}/items", + params=[("sortby", "-properties.datetime")], + ) + assert resp.status_code == 200 + resp_json = resp.json() + assert resp_json["features"][0]["id"] == first_item["id"] + assert resp_json["features"][1]["id"] == second_item["id"] + + +@pytest.mark.asyncio +async def test_item_collection_sort_asc(app_client, txn_client, ctx): + """Verify GET /collections/{collectionId}/items honors ascending sort on properties.datetime.""" + first_item = ctx.item + + # Create a second item in the same collection with an earlier datetime + second_item = dict(first_item) + second_item["id"] = "another-item-for-collection-sort-asc" + another_item_date = datetime.strptime( + first_item["properties"]["datetime"], "%Y-%m-%dT%H:%M:%SZ" + ) - timedelta(days=1) + second_item["properties"]["datetime"] = another_item_date.strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + + await create_item(txn_client, second_item) + + # Ascending sort: the older item should come first + resp = await app_client.get( + f"/collections/{first_item['collection']}/items", + params=[("sortby", "+properties.datetime")], + ) + assert resp.status_code == 200 + resp_json = resp.json() + assert resp_json["features"][0]["id"] == second_item["id"] + assert resp_json["features"][1]["id"] == first_item["id"] + + # Also verify bare field (no +) sorts ascending by default + resp = await app_client.get( + f"/collections/{first_item['collection']}/items", + params=[("sortby", "properties.datetime")], + ) + assert resp.status_code == 200 + resp_json = resp.json() + assert resp_json["features"][0]["id"] == second_item["id"] + assert resp_json["features"][1]["id"] == first_item["id"] + + +@pytest.mark.asyncio +async def test_item_collection_query(app_client, txn_client, ctx): + """Simple query parameter test on the Item Collection route. + + Creates an item with a unique property and ensures it can be retrieved + using the 'query' parameter on GET /collections/{collection_id}/items. + """ + unique_val = str(uuid.uuid4()) + test_item = deepcopy(ctx.item) + test_item["id"] = f"query-basic-{unique_val}" + # Add a property to filter on + test_item.setdefault("properties", {})["test_query_key"] = unique_val + + await create_item(txn_client, test_item) + + # Provide the query parameter as a JSON string without adding new imports + query_param = f'{{"test_query_key": {{"eq": "{unique_val}"}}}}' + + resp = await app_client.get( + f"/collections/{test_item['collection']}/items", + params=[("query", query_param)], + ) + assert resp.status_code == 200 + resp_json = resp.json() + ids = [f["id"] for f in resp_json["features"]] + assert test_item["id"] in ids + + +@pytest.mark.asyncio +async def test_item_collection_filter_by_id(app_client, ctx): + """Test filtering items by ID using the filter parameter.""" + # Get the test item and collection from the context + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + + # Create a filter to match the item by ID + filter_body = {"op": "=", "args": [{"property": "id"}, item_id]} + + # Make the request with the filter + params = [("filter", json.dumps(filter_body)), ("filter-lang", "cql2-json")] + + resp = await app_client.get( + f"/collections/{collection_id}/items", + params=params, + ) + + # Verify the response + assert resp.status_code == 200 + resp_json = resp.json() + + # Should find exactly one matching item + assert len(resp_json["features"]) == 1 + assert resp_json["features"][0]["id"] == item_id + assert resp_json["features"][0]["collection"] == collection_id + + +@pytest.mark.asyncio +async def test_item_collection_filter_by_nonexistent_id(app_client, ctx, txn_client): + """Test filtering with a non-existent ID returns no results.""" + # Get the test collection and item from context + collection_id = ctx.collection["id"] + item_id = ctx.item["id"] + + # First, verify the item exists + resp = await app_client.get(f"/collections/{collection_id}/items/{item_id}") + assert resp.status_code == 200 + + # Create a non-existent ID + non_existent_id = f"non-existent-{str(uuid.uuid4())}" + + # Create a filter with the non-existent ID using CQL2-JSON syntax + filter_body = {"op": "=", "args": [{"property": "id"}, non_existent_id]} + + # URL-encode the filter JSON + import urllib.parse + + encoded_filter = urllib.parse.quote(json.dumps(filter_body)) + + # Make the request with URL-encoded filter in the query string + url = f"/collections/{collection_id}/items?filter-lang=cql2-json&filter={encoded_filter}" + resp = await app_client.get(url) + + # Verify the response + assert resp.status_code == 200 + resp_json = resp.json() + assert ( + len(resp_json["features"]) == 0 + ), f"Expected no items with ID {non_existent_id}, but found {len(resp_json['features'])} matches" From 4f2ff6d82f32b442ee5e114c07f2d97d2812ee34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zuzanna=20Mr=C3=B3z?= Date: Mon, 15 Sep 2025 10:31:34 +0200 Subject: [PATCH 28/30] fields extension update --- stac_fastapi/core/stac_fastapi/core/core.py | 3 +++ .../elasticsearch/stac_fastapi/elasticsearch/app.py | 6 ++---- stac_fastapi/opensearch/stac_fastapi/opensearch/app.py | 6 ++---- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index 7b4502669..627b02736 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -293,6 +293,7 @@ async def item_collection( filter_lang: Optional[str] = None, token: Optional[str] = None, query: Optional[str] = None, + fields: Optional[List[str]] = None, **kwargs, ) -> stac_types.ItemCollection: """List items within a specific collection. @@ -314,6 +315,7 @@ async def item_collection( query (Optional[str]): Optional query string. filter_expr (Optional[str]): Optional filter expression. filter_lang (Optional[str]): Optional filter language. + fields (Optional[List[str]]): Fields to include or exclude from the results. Returns: ItemCollection: Feature collection with items, paging links, and counts. @@ -338,6 +340,7 @@ async def item_collection( query=query, filter_expr=filter_expr, filter_lang=filter_lang, + fields=fields, ) async def get_item( diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py index fccb418a5..ce6ce341d 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py @@ -86,11 +86,8 @@ aggregation_extension.POST = EsAggregationExtensionPostRequest aggregation_extension.GET = EsAggregationExtensionGetRequest -fields_extension = FieldsExtension() -fields_extension.conformance_classes.append(FieldsConformanceClasses.ITEMS) - search_extensions = [ - fields_extension, + FieldsExtension(), QueryExtension(), SortExtension(), TokenPaginationExtension(), @@ -137,6 +134,7 @@ conformance_classes=[QueryConformanceClasses.ITEMS], ), filter_extension, + FieldsExtension(conformance_classes=[FieldsConformanceClasses.ITEMS]), ], request_type="GET", ) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py index e1fdd882e..dab4b6331 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py @@ -85,11 +85,8 @@ aggregation_extension.POST = EsAggregationExtensionPostRequest aggregation_extension.GET = EsAggregationExtensionGetRequest -fields_extension = FieldsExtension() -fields_extension.conformance_classes.append(FieldsConformanceClasses.ITEMS) - search_extensions = [ - fields_extension, + FieldsExtension(), QueryExtension(), SortExtension(), TokenPaginationExtension(), @@ -137,6 +134,7 @@ conformance_classes=[QueryConformanceClasses.ITEMS], ), filter_extension, + FieldsExtension(conformance_classes=[FieldsConformanceClasses.ITEMS]), ], request_type="GET", ) From 3392afb297955e5839b53d527f95af081d0a2f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zuzanna=20Mr=C3=B3z?= Date: Mon, 15 Sep 2025 10:38:02 +0200 Subject: [PATCH 29/30] move item collection fields extension api tests --- stac_fastapi/tests/api/test_api.py | 68 ------------------- .../tests/api/test_api_item_collection.py | 68 +++++++++++++++++++ 2 files changed, 68 insertions(+), 68 deletions(-) diff --git a/stac_fastapi/tests/api/test_api.py b/stac_fastapi/tests/api/test_api.py index 699617c70..9387505b5 100644 --- a/stac_fastapi/tests/api/test_api.py +++ b/stac_fastapi/tests/api/test_api.py @@ -228,74 +228,6 @@ async def test_app_fields_extension_return_all_properties( assert feature["properties"][expected_prop] == expected_value -@pytest.mark.asyncio -async def test_app_fields_extension_collection_items(app_client, ctx, txn_client): - resp = await app_client.get( - "/collections/test-collection/items", - params={"fields": "+properties.datetime"}, - ) - assert resp.status_code == 200 - resp_json = resp.json() - assert list(resp_json["features"][0]["properties"]) == ["datetime"] - - -@pytest.mark.asyncio -async def test_app_fields_extension_no_properties_get_collection_items( - app_client, ctx, txn_client -): - resp = await app_client.get( - "/collections/test-collection/items", params={"fields": "-properties"} - ) - assert resp.status_code == 200 - resp_json = resp.json() - assert "properties" not in resp_json["features"][0] - - -@pytest.mark.asyncio -async def test_app_fields_extension_no_null_fields_collection_items( - app_client, ctx, txn_client -): - resp = await app_client.get("/collections/test-collection/items") - assert resp.status_code == 200 - resp_json = resp.json() - # check if no null fields: https://github.com/stac-utils/stac-fastapi-elasticsearch/issues/166 - for feature in resp_json["features"]: - # assert "bbox" not in feature["geometry"] - for link in feature["links"]: - assert all(a not in link or link[a] is not None for a in ("title", "asset")) - for asset in feature["assets"]: - assert all( - a not in asset or asset[a] is not None - for a in ("start_datetime", "created") - ) - - -@pytest.mark.asyncio -async def test_app_fields_extension_return_all_properties_collection_items( - app_client, ctx, txn_client, load_test_data -): - item = load_test_data("test_item.json") - resp = await app_client.get( - "/collections/test-collection/items", - params={"collections": ["test-collection"], "fields": "properties"}, - ) - assert resp.status_code == 200 - resp_json = resp.json() - feature = resp_json["features"][0] - assert len(feature["properties"]) >= len(item["properties"]) - for expected_prop, expected_value in item["properties"].items(): - if expected_prop in ( - "datetime", - "start_datetime", - "end_datetime", - "created", - "updated", - ): - assert feature["properties"][expected_prop][0:19] == expected_value[0:19] - else: - assert feature["properties"][expected_prop] == expected_value - - @pytest.mark.asyncio async def test_app_query_extension_gt(app_client, ctx): params = {"query": {"proj:epsg": {"gt": ctx.item["properties"]["proj:epsg"]}}} diff --git a/stac_fastapi/tests/api/test_api_item_collection.py b/stac_fastapi/tests/api/test_api_item_collection.py index 7c7b51b6f..2b1aa8e79 100644 --- a/stac_fastapi/tests/api/test_api_item_collection.py +++ b/stac_fastapi/tests/api/test_api_item_collection.py @@ -190,3 +190,71 @@ async def test_item_collection_filter_by_nonexistent_id(app_client, ctx, txn_cli assert ( len(resp_json["features"]) == 0 ), f"Expected no items with ID {non_existent_id}, but found {len(resp_json['features'])} matches" + + +@pytest.mark.asyncio +async def test_item_collection_fields_extension(app_client, ctx, txn_client): + resp = await app_client.get( + "/collections/test-collection/items", + params={"fields": "+properties.datetime"}, + ) + assert resp.status_code == 200 + resp_json = resp.json() + assert list(resp_json["features"][0]["properties"]) == ["datetime"] + + +@pytest.mark.asyncio +async def test_item_collection_fields_extension_no_properties_get( + app_client, ctx, txn_client +): + resp = await app_client.get( + "/collections/test-collection/items", params={"fields": "-properties"} + ) + assert resp.status_code == 200 + resp_json = resp.json() + assert "properties" not in resp_json["features"][0] + + +@pytest.mark.asyncio +async def test_item_collection_fields_extension_no_null_fields( + app_client, ctx, txn_client +): + resp = await app_client.get("/collections/test-collection/items") + assert resp.status_code == 200 + resp_json = resp.json() + # check if no null fields: https://github.com/stac-utils/stac-fastapi-elasticsearch/issues/166 + for feature in resp_json["features"]: + # assert "bbox" not in feature["geometry"] + for link in feature["links"]: + assert all(a not in link or link[a] is not None for a in ("title", "asset")) + for asset in feature["assets"]: + assert all( + a not in asset or asset[a] is not None + for a in ("start_datetime", "created") + ) + + +@pytest.mark.asyncio +async def test_item_collection_fields_extension_return_all_properties( + app_client, ctx, txn_client, load_test_data +): + item = load_test_data("test_item.json") + resp = await app_client.get( + "/collections/test-collection/items", + params={"collections": ["test-collection"], "fields": "properties"}, + ) + assert resp.status_code == 200 + resp_json = resp.json() + feature = resp_json["features"][0] + assert len(feature["properties"]) >= len(item["properties"]) + for expected_prop, expected_value in item["properties"].items(): + if expected_prop in ( + "datetime", + "start_datetime", + "end_datetime", + "created", + "updated", + ): + assert feature["properties"][expected_prop][0:19] == expected_value[0:19] + else: + assert feature["properties"][expected_prop] == expected_value From b54ed928ac560415787db401627f86ee9c244787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zuzanna=20Mr=C3=B3z?= Date: Tue, 16 Sep 2025 10:11:12 +0200 Subject: [PATCH 30/30] code review fixes --- CHANGELOG.md | 2 +- .../elasticsearch/stac_fastapi/elasticsearch/app.py | 5 ++++- stac_fastapi/opensearch/stac_fastapi/opensearch/app.py | 5 ++++- stac_fastapi/tests/conftest.py | 6 +----- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99e0626ec..5c6fd3590 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `STAC_INDEX_ASSETS` environment variable to allow asset serialization to be configurable. [#433](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/433) - 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) - Sort, Query, and Filter extension and functionality to the item collection route. [#437](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/437) -- Added Fields Extension implementation for the `/collections/{collection_id}/aggregations` endpoint. [#436](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/436) +- Added Fields Extension implementation for the `/collections/{collection_id}/items` endpoint. [#436](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/436) ### Changed diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py index ce6ce341d..d83dc9f58 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py @@ -86,8 +86,11 @@ aggregation_extension.POST = EsAggregationExtensionPostRequest aggregation_extension.GET = EsAggregationExtensionGetRequest +fields_extension = FieldsExtension() +fields_extension.conformance_classes.append(FieldsConformanceClasses.ITEMS) + search_extensions = [ - FieldsExtension(), + fields_extension, QueryExtension(), SortExtension(), TokenPaginationExtension(), diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py index dab4b6331..156558f02 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py @@ -85,8 +85,11 @@ aggregation_extension.POST = EsAggregationExtensionPostRequest aggregation_extension.GET = EsAggregationExtensionGetRequest +fields_extension = FieldsExtension() +fields_extension.conformance_classes.append(FieldsConformanceClasses.ITEMS) + search_extensions = [ - FieldsExtension(), + fields_extension, QueryExtension(), SortExtension(), TokenPaginationExtension(), diff --git a/stac_fastapi/tests/conftest.py b/stac_fastapi/tests/conftest.py index 343adfb8b..08e3277dc 100644 --- a/stac_fastapi/tests/conftest.py +++ b/stac_fastapi/tests/conftest.py @@ -25,7 +25,6 @@ ) from stac_fastapi.core.rate_limit import setup_rate_limit from stac_fastapi.core.utilities import get_bool_env -from stac_fastapi.extensions.core.fields import FieldsConformanceClasses from stac_fastapi.sfeos_helpers.aggregation import EsAsyncBaseAggregationClient from stac_fastapi.sfeos_helpers.mappings import ITEMS_INDEX_PREFIX @@ -362,11 +361,8 @@ def build_test_app(): aggregation_extension.POST = EsAggregationExtensionPostRequest aggregation_extension.GET = EsAggregationExtensionGetRequest - fields_extension = FieldsExtension() - fields_extension.conformance_classes.append(FieldsConformanceClasses.ITEMS) - search_extensions = [ - fields_extension, + FieldsExtension(), SortExtension(), QueryExtension(), TokenPaginationExtension(),