Skip to content

Commit c5891e1

Browse files
authored
Update to stac-fastapi v3.0.0a3, remove deprecated filter fields (#269)
**Related Issue(s):** - #217 - stac-utils/stac-fastapi#642 - stac-utils/stac-fastapi@d8528ae **Description:** - Update to stac-fastapi v3.0.0a3 - Remove deprecated filter_fields - Default to returning all properties, copy stac-fastapi-pgstac behaviour for now **PR Checklist:** - [x] Code is formatted and linted (run `pre-commit run --all-files`) - [x] Tests pass (run `make test`) - [x] Documentation has been updated to reflect changes, if applicable - [x] Changes are added to the changelog
1 parent a416ec0 commit c5891e1

File tree

10 files changed

+203
-62
lines changed

10 files changed

+203
-62
lines changed

.github/workflows/cicd.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: stac-fastapi-elasticsearch
1+
name: sfeos
22

33
on:
44
push:

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1313
### Changed
1414

1515
- Updated stac-fastapi libraries to v3.0.0a1 [#265](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/265)
16+
- Updated stac-fastapi libraries to v3.0.0a3 [#269](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/269)
1617

1718
### Fixed
1819

1920
- API sort extension tests [#264](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/264)
2021
- Basic auth permission fix for checking route path instead of absolute path [#266](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/266)
22+
- Remove deprecated filter_fields property, return all properties as default [#269](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/269)
2123

2224
## [v3.0.0a1]
2325

stac_fastapi/core/setup.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
"attrs>=23.2.0",
1111
"pydantic[dotenv]",
1212
"stac_pydantic>=3",
13-
"stac-fastapi.types==3.0.0a1",
14-
"stac-fastapi.api==3.0.0a1",
15-
"stac-fastapi.extensions==3.0.0a1",
13+
"stac-fastapi.types==3.0.0a3",
14+
"stac-fastapi.api==3.0.0a3",
15+
"stac-fastapi.extensions==3.0.0a3",
1616
"orjson",
1717
"overrides",
1818
"geojson-pydantic",

stac_fastapi/core/stac_fastapi/core/core.py

Lines changed: 29 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99

1010
import attr
1111
import orjson
12-
import stac_pydantic
1312
from fastapi import HTTPException, Request
1413
from overrides import overrides
1514
from pydantic import ValidationError
@@ -25,19 +24,16 @@
2524
from stac_fastapi.core.models.links import PagingLinks
2625
from stac_fastapi.core.serializers import CollectionSerializer, ItemSerializer
2726
from stac_fastapi.core.session import Session
27+
from stac_fastapi.core.utilities import filter_fields
28+
from stac_fastapi.extensions.core.filter.client import AsyncBaseFiltersClient
2829
from stac_fastapi.extensions.third_party.bulk_transactions import (
2930
BaseBulkTransactionsClient,
3031
BulkTransactionMethod,
3132
Items,
3233
)
3334
from stac_fastapi.types import stac as stac_types
34-
from stac_fastapi.types.config import Settings
3535
from stac_fastapi.types.conformance import BASE_CONFORMANCE_CLASSES
36-
from stac_fastapi.types.core import (
37-
AsyncBaseCoreClient,
38-
AsyncBaseFiltersClient,
39-
AsyncBaseTransactionsClient,
40-
)
36+
from stac_fastapi.types.core import AsyncBaseCoreClient, AsyncBaseTransactionsClient
4137
from stac_fastapi.types.extension import ApiExtension
4238
from stac_fastapi.types.requests import get_base_url
4339
from stac_fastapi.types.rfc3339 import DateTimeType
@@ -491,34 +487,26 @@ async def get_search(
491487
base_args["intersects"] = orjson.loads(unquote_plus(intersects))
492488

493489
if sortby:
494-
sort_param = []
495-
for sort in sortby:
496-
sort_param.append(
497-
{
498-
"field": sort[1:],
499-
"direction": "desc" if sort[0] == "-" else "asc",
500-
}
501-
)
502-
base_args["sortby"] = sort_param
490+
base_args["sortby"] = [
491+
{"field": sort[1:], "direction": "desc" if sort[0] == "-" else "asc"}
492+
for sort in sortby
493+
]
503494

504495
if filter:
505-
if filter_lang == "cql2-json":
506-
base_args["filter-lang"] = "cql2-json"
507-
base_args["filter"] = orjson.loads(unquote_plus(filter))
508-
else:
509-
base_args["filter-lang"] = "cql2-json"
510-
base_args["filter"] = orjson.loads(to_cql2(parse_cql2_text(filter)))
496+
base_args["filter-lang"] = "cql2-json"
497+
base_args["filter"] = orjson.loads(
498+
unquote_plus(filter)
499+
if filter_lang == "cql2-json"
500+
else to_cql2(parse_cql2_text(filter))
501+
)
511502

512503
if fields:
513-
includes = set()
514-
excludes = set()
504+
includes, excludes = set(), set()
515505
for field in fields:
516506
if field[0] == "-":
517507
excludes.add(field[1:])
518-
elif field[0] == "+":
519-
includes.add(field[1:])
520508
else:
521-
includes.add(field)
509+
includes.add(field[1:] if field[0] in "+ " else field)
522510
base_args["fields"] = {"include": includes, "exclude": excludes}
523511

524512
# Do the request
@@ -614,32 +602,22 @@ async def post_search(
614602
collection_ids=search_request.collections,
615603
)
616604

605+
fields = (
606+
getattr(search_request, "fields", None)
607+
if self.extension_is_enabled("FieldsExtension")
608+
else None
609+
)
610+
include: Set[str] = fields.include if fields and fields.include else set()
611+
exclude: Set[str] = fields.exclude if fields and fields.exclude else set()
612+
617613
items = [
618-
self.item_serializer.db_to_stac(item, base_url=base_url) for item in items
614+
filter_fields(
615+
self.item_serializer.db_to_stac(item, base_url=base_url),
616+
include,
617+
exclude,
618+
)
619+
for item in items
619620
]
620-
621-
if self.extension_is_enabled("FieldsExtension"):
622-
if search_request.query is not None:
623-
query_include: Set[str] = set(
624-
[
625-
k if k in Settings.get().indexed_fields else f"properties.{k}"
626-
for k in search_request.query.keys()
627-
]
628-
)
629-
if not search_request.fields.include:
630-
search_request.fields.include = query_include
631-
else:
632-
search_request.fields.include.union(query_include)
633-
634-
filter_kwargs = search_request.fields.filter_fields
635-
636-
items = [
637-
orjson.loads(
638-
stac_pydantic.Item(**feat).json(**filter_kwargs, exclude_unset=True)
639-
)
640-
for feat in items
641-
]
642-
643621
links = await PagingLinks(request=request, next=next_token).get_links()
644622

645623
return stac_types.ItemCollection(
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Fields extension."""
2+
3+
from typing import Optional, Set
4+
5+
from pydantic import BaseModel, Field
6+
7+
from stac_fastapi.extensions.core import FieldsExtension as FieldsExtensionBase
8+
from stac_fastapi.extensions.core.fields import request
9+
10+
11+
class PostFieldsExtension(request.PostFieldsExtension):
12+
"""PostFieldsExtension."""
13+
14+
# Set defaults if needed
15+
# include : Optional[Set[str]] = Field(
16+
# default_factory=lambda: {
17+
# "id",
18+
# "type",
19+
# "stac_version",
20+
# "geometry",
21+
# "bbox",
22+
# "links",
23+
# "assets",
24+
# "properties.datetime",
25+
# "collection",
26+
# }
27+
# )
28+
include: Optional[Set[str]] = set()
29+
exclude: Optional[Set[str]] = set()
30+
31+
32+
class FieldsExtensionPostRequest(BaseModel):
33+
"""Additional fields and schema for the POST request."""
34+
35+
fields: Optional[PostFieldsExtension] = Field(PostFieldsExtension())
36+
37+
38+
class FieldsExtension(FieldsExtensionBase):
39+
"""Override the POST model."""
40+
41+
POST = FieldsExtensionPostRequest

stac_fastapi/core/stac_fastapi/core/utilities.py

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
This module contains functions for transforming geospatial coordinates,
44
such as converting bounding boxes to polygon representations.
55
"""
6-
from typing import List
6+
from typing import Any, Dict, List, Optional, Set, Union
7+
8+
from stac_fastapi.types.stac import Item
79

810
MAX_LIMIT = 10000
911

@@ -21,3 +23,113 @@ def bbox2polygon(b0: float, b1: float, b2: float, b3: float) -> List[List[List[f
2123
List[List[List[float]]]: A polygon represented as a list of lists of coordinates.
2224
"""
2325
return [[[b0, b1], [b2, b1], [b2, b3], [b0, b3], [b0, b1]]]
26+
27+
28+
# copied from stac-fastapi-pgstac
29+
# https://github.com/stac-utils/stac-fastapi-pgstac/blob/26f6d918eb933a90833f30e69e21ba3b4e8a7151/stac_fastapi/pgstac/utils.py#L10-L116
30+
def filter_fields( # noqa: C901
31+
item: Union[Item, Dict[str, Any]],
32+
include: Optional[Set[str]] = None,
33+
exclude: Optional[Set[str]] = None,
34+
) -> Item:
35+
"""Preserve and remove fields as indicated by the fields extension include/exclude sets.
36+
37+
Returns a shallow copy of the Item with the fields filtered.
38+
39+
This will not perform a deep copy; values of the original item will be referenced
40+
in the return item.
41+
"""
42+
if not include and not exclude:
43+
return item
44+
45+
# Build a shallow copy of included fields on an item, or a sub-tree of an item
46+
def include_fields(
47+
source: Dict[str, Any], fields: Optional[Set[str]]
48+
) -> Dict[str, Any]:
49+
if not fields:
50+
return source
51+
52+
clean_item: Dict[str, Any] = {}
53+
for key_path in fields or []:
54+
key_path_parts = key_path.split(".")
55+
key_root = key_path_parts[0]
56+
if key_root in source:
57+
if isinstance(source[key_root], dict) and len(key_path_parts) > 1:
58+
# The root of this key path on the item is a dict, and the
59+
# key path indicates a sub-key to be included. Walk the dict
60+
# from the root key and get the full nested value to include.
61+
value = include_fields(
62+
source[key_root], fields={".".join(key_path_parts[1:])}
63+
)
64+
65+
if isinstance(clean_item.get(key_root), dict):
66+
# A previously specified key and sub-keys may have been included
67+
# already, so do a deep merge update if the root key already exists.
68+
dict_deep_update(clean_item[key_root], value)
69+
else:
70+
# The root key does not exist, so add it. Fields
71+
# extension only allows nested referencing on dicts, so
72+
# this won't overwrite anything.
73+
clean_item[key_root] = value
74+
else:
75+
# The item value to include is not a dict, or, it is a dict but the
76+
# key path is for the whole value, not a sub-key. Include the entire
77+
# value in the cleaned item.
78+
clean_item[key_root] = source[key_root]
79+
else:
80+
# The key, or root key of a multi-part key, is not present in the item,
81+
# so it is ignored
82+
pass
83+
return clean_item
84+
85+
# For an item built up for included fields, remove excluded fields. This
86+
# modifies `source` in place.
87+
def exclude_fields(source: Dict[str, Any], fields: Optional[Set[str]]) -> None:
88+
for key_path in fields or []:
89+
key_path_part = key_path.split(".")
90+
key_root = key_path_part[0]
91+
if key_root in source:
92+
if isinstance(source[key_root], dict) and len(key_path_part) > 1:
93+
# Walk the nested path of this key to remove the leaf-key
94+
exclude_fields(
95+
source[key_root], fields={".".join(key_path_part[1:])}
96+
)
97+
# If, after removing the leaf-key, the root is now an empty
98+
# dict, remove it entirely
99+
if not source[key_root]:
100+
del source[key_root]
101+
else:
102+
# The key's value is not a dict, or there is no sub-key to remove. The
103+
# entire key can be removed from the source.
104+
source.pop(key_root, None)
105+
106+
# Coalesce incoming type to a dict
107+
item = dict(item)
108+
109+
clean_item = include_fields(item, include)
110+
111+
# If, after including all the specified fields, there are no included properties,
112+
# return just id and collection.
113+
if not clean_item:
114+
return Item({"id": item["id"], "collection": item["collection"]})
115+
116+
exclude_fields(clean_item, exclude)
117+
118+
return Item(**clean_item)
119+
120+
121+
def dict_deep_update(merge_to: Dict[str, Any], merge_from: Dict[str, Any]) -> None:
122+
"""Perform a deep update of two dicts.
123+
124+
merge_to is updated in-place with the values from merge_from.
125+
merge_from values take precedence over existing values in merge_to.
126+
"""
127+
for k, v in merge_from.items():
128+
if (
129+
k in merge_to
130+
and isinstance(merge_to[k], dict)
131+
and isinstance(merge_from[k], dict)
132+
):
133+
dict_deep_update(merge_to[k], merge_from[k])
134+
else:
135+
merge_to[k] = v

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
TransactionsClient,
1313
)
1414
from stac_fastapi.core.extensions import QueryExtension
15+
from stac_fastapi.core.extensions.fields import FieldsExtension
1516
from stac_fastapi.core.session import Session
1617
from stac_fastapi.elasticsearch.config import ElasticsearchSettings
1718
from stac_fastapi.elasticsearch.database_logic import (
@@ -20,7 +21,6 @@
2021
create_index_templates,
2122
)
2223
from stac_fastapi.extensions.core import (
23-
FieldsExtension,
2424
FilterExtension,
2525
SortExtension,
2626
TokenPaginationExtension,

stac_fastapi/opensearch/stac_fastapi/opensearch/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212
TransactionsClient,
1313
)
1414
from stac_fastapi.core.extensions import QueryExtension
15+
from stac_fastapi.core.extensions.fields import FieldsExtension
1516
from stac_fastapi.core.session import Session
1617
from stac_fastapi.extensions.core import (
17-
FieldsExtension,
1818
FilterExtension,
1919
SortExtension,
2020
TokenPaginationExtension,

stac_fastapi/tests/api/test_api.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,10 @@ async def test_app_context_results(app_client, txn_client, ctx, load_test_data):
118118

119119
@pytest.mark.asyncio
120120
async def test_app_fields_extension(app_client, ctx, txn_client):
121-
resp = await app_client.get("/search", params={"collections": ["test-collection"]})
121+
resp = await app_client.get(
122+
"/search",
123+
params={"collections": ["test-collection"], "fields": "+properties.datetime"},
124+
)
122125
assert resp.status_code == 200
123126
resp_json = resp.json()
124127
assert list(resp_json["features"][0]["properties"]) == ["datetime"]
@@ -132,11 +135,12 @@ async def test_app_fields_extension_query(app_client, ctx, txn_client):
132135
json={
133136
"query": {"proj:epsg": {"gte": item["properties"]["proj:epsg"]}},
134137
"collections": ["test-collection"],
138+
"fields": {"include": ["properties.datetime", "properties.proj:epsg"]},
135139
},
136140
)
137141
assert resp.status_code == 200
138142
resp_json = resp.json()
139-
assert list(resp_json["features"][0]["properties"]) == ["datetime", "proj:epsg"]
143+
assert set(resp_json["features"][0]["properties"]) == set(["datetime", "proj:epsg"])
140144

141145

142146
@pytest.mark.asyncio

stac_fastapi/tests/resources/test_item.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -754,7 +754,11 @@ async def test_field_extension_post(app_client, ctx):
754754
"ids": [test_item["id"]],
755755
"fields": {
756756
"exclude": ["assets.B1"],
757-
"include": ["properties.eo:cloud_cover", "properties.orientation"],
757+
"include": [
758+
"properties.eo:cloud_cover",
759+
"properties.orientation",
760+
"assets",
761+
],
758762
},
759763
}
760764

@@ -782,7 +786,7 @@ async def test_field_extension_exclude_and_include(app_client, ctx):
782786

783787
resp = await app_client.post("/search", json=body)
784788
resp_json = resp.json()
785-
assert "eo:cloud_cover" not in resp_json["features"][0]["properties"]
789+
assert "properties" not in resp_json["features"][0]
786790

787791

788792
@pytest.mark.asyncio

0 commit comments

Comments
 (0)