diff --git a/CHANGELOG.md b/CHANGELOG.md index 24d0e6fd1..5c6fd3590 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +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}/items` endpoint. [#436](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/436) ### Changed 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 34c582c19..d83dc9f58 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py @@ -43,6 +43,7 @@ TokenPaginationExtension, TransactionExtension, ) +from stac_fastapi.extensions.core.fields import FieldsConformanceClasses from stac_fastapi.extensions.core.filter import FilterConformanceClasses from stac_fastapi.extensions.core.query import QueryConformanceClasses from stac_fastapi.extensions.core.sort import SortConformanceClasses @@ -85,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(), @@ -133,6 +137,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 26b4bc0ca..156558f02 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py @@ -37,6 +37,7 @@ TokenPaginationExtension, TransactionExtension, ) +from stac_fastapi.extensions.core.fields import FieldsConformanceClasses from stac_fastapi.extensions.core.filter import FilterConformanceClasses from stac_fastapi.extensions.core.query import QueryConformanceClasses from stac_fastapi.extensions.core.sort import SortConformanceClasses @@ -84,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(), @@ -133,6 +137,7 @@ conformance_classes=[QueryConformanceClasses.ITEMS], ), filter_extension, + FieldsExtension(conformance_classes=[FieldsConformanceClasses.ITEMS]), ], request_type="GET", ) 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 diff --git a/stac_fastapi/tests/conftest.py b/stac_fastapi/tests/conftest.py index 23da26687..08e3277dc 100644 --- a/stac_fastapi/tests/conftest.py +++ b/stac_fastapi/tests/conftest.py @@ -362,8 +362,8 @@ def build_test_app(): aggregation_extension.GET = EsAggregationExtensionGetRequest search_extensions = [ - SortExtension(), FieldsExtension(), + SortExtension(), 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)"""