Skip to content

Commit 3bcd739

Browse files
committed
Merge branch 'main' of github.com:stac-utils/stac-fastapi-elasticsearch into patch_endpoints
2 parents 502da27 + 8a6a3e6 commit 3bcd739

File tree

17 files changed

+363
-340
lines changed

17 files changed

+363
-340
lines changed

CHANGELOG.md

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

99
## [Unreleased]
1010

11+
### Added
12+
13+
- Added support for enum queryables [#390](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/390)
14+
15+
### Changed
16+
17+
- Optimize data_loader.py script [#395](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/395)
18+
- Refactored test configuration to use shared app config pattern [#399](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/399)
19+
20+
### Removed
21+
22+
- Removed `requests` dev dependency [#395](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/395)
23+
1124
## [v5.0.0a1] - 2025-05-30
1225

1326
### Changed

Makefile

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,11 @@ APP_HOST ?= 0.0.0.0
33
EXTERNAL_APP_PORT ?= 8080
44

55
ES_APP_PORT ?= 8080
6+
OS_APP_PORT ?= 8082
7+
68
ES_HOST ?= docker.for.mac.localhost
79
ES_PORT ?= 9200
810

9-
OS_APP_PORT ?= 8082
10-
OS_HOST ?= docker.for.mac.localhost
11-
OS_PORT ?= 9202
12-
1311
run_es = docker compose \
1412
run \
1513
-p ${EXTERNAL_APP_PORT}:${ES_APP_PORT} \

data_loader.py

Lines changed: 73 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,106 +1,105 @@
11
"""Data Loader CLI STAC_API Ingestion Tool."""
2-
import json
2+
33
import os
4+
from typing import Any
45

56
import click
6-
import requests
7+
import orjson
8+
from httpx import Client
79

810

9-
def load_data(data_dir, filename):
11+
def load_data(filepath: str) -> dict[str, Any]:
1012
"""Load json data from a file within the specified data directory."""
11-
filepath = os.path.join(data_dir, filename)
12-
if not os.path.exists(filepath):
13+
try:
14+
with open(filepath, "rb") as file:
15+
return orjson.loads(file.read())
16+
except FileNotFoundError as e:
1317
click.secho(f"File not found: {filepath}", fg="red", err=True)
14-
raise click.Abort()
15-
with open(filepath) as file:
16-
return json.load(file)
18+
raise click.Abort() from e
1719

1820

19-
def load_collection(base_url, collection_id, data_dir):
21+
def load_collection(client: Client, collection_id: str, data_dir: str) -> None:
2022
"""Load a STAC collection into the database."""
21-
collection = load_data(data_dir, "collection.json")
23+
collection = load_data(os.path.join(data_dir, "collection.json"))
2224
collection["id"] = collection_id
23-
try:
24-
resp = requests.post(f"{base_url}/collections", json=collection)
25-
if resp.status_code == 200 or resp.status_code == 201:
26-
click.echo(f"Status code: {resp.status_code}")
27-
click.echo(f"Added collection: {collection['id']}")
28-
elif resp.status_code == 409:
29-
click.echo(f"Status code: {resp.status_code}")
30-
click.echo(f"Collection: {collection['id']} already exists")
31-
else:
32-
click.echo(f"Status code: {resp.status_code}")
33-
click.echo(
34-
f"Error writing {collection['id']} collection. Message: {resp.text}"
35-
)
36-
except requests.ConnectionError:
37-
click.secho("Failed to connect", fg="red", err=True)
25+
resp = client.post("/collections", json=collection)
26+
if resp.status_code == 200 or resp.status_code == 201:
27+
click.echo(f"Status code: {resp.status_code}")
28+
click.echo(f"Added collection: {collection['id']}")
29+
elif resp.status_code == 409:
30+
click.echo(f"Status code: {resp.status_code}")
31+
click.echo(f"Collection: {collection['id']} already exists")
32+
else:
33+
click.echo(f"Status code: {resp.status_code}")
34+
click.echo(f"Error writing {collection['id']} collection. Message: {resp.text}")
3835

3936

40-
def load_items(base_url, collection_id, use_bulk, data_dir):
37+
def load_items(
38+
client: Client, collection_id: str, use_bulk: bool, data_dir: str
39+
) -> None:
4140
"""Load STAC items into the database based on the method selected."""
42-
# Attempt to dynamically find a suitable feature collection file
43-
feature_files = [
44-
file
45-
for file in os.listdir(data_dir)
46-
if file.endswith(".json") and file != "collection.json"
47-
]
48-
if not feature_files:
41+
with os.scandir(data_dir) as entries:
42+
# Attempt to dynamically find a suitable feature collection file
43+
# Use the first found feature collection file
44+
feature_file = next(
45+
(
46+
entry.path
47+
for entry in entries
48+
if entry.is_file()
49+
and entry.name.endswith(".json")
50+
and entry.name != "collection.json"
51+
),
52+
None,
53+
)
54+
55+
if feature_file is None:
4956
click.secho(
5057
"No feature collection files found in the specified directory.",
5158
fg="red",
5259
err=True,
5360
)
5461
raise click.Abort()
55-
feature_collection_file = feature_files[
56-
0
57-
] # Use the first found feature collection file
58-
feature_collection = load_data(data_dir, feature_collection_file)
5962

60-
load_collection(base_url, collection_id, data_dir)
63+
feature_collection = load_data(feature_file)
64+
65+
load_collection(client, collection_id, data_dir)
6166
if use_bulk:
62-
load_items_bulk_insert(base_url, collection_id, feature_collection, data_dir)
67+
load_items_bulk_insert(client, collection_id, feature_collection)
6368
else:
64-
load_items_one_by_one(base_url, collection_id, feature_collection, data_dir)
69+
load_items_one_by_one(client, collection_id, feature_collection)
6570

6671

67-
def load_items_one_by_one(base_url, collection_id, feature_collection, data_dir):
72+
def load_items_one_by_one(
73+
client: Client, collection_id: str, feature_collection: dict[str, Any]
74+
) -> None:
6875
"""Load STAC items into the database one by one."""
6976
for feature in feature_collection["features"]:
70-
try:
71-
feature["collection"] = collection_id
72-
resp = requests.post(
73-
f"{base_url}/collections/{collection_id}/items", json=feature
74-
)
75-
if resp.status_code == 200:
76-
click.echo(f"Status code: {resp.status_code}")
77-
click.echo(f"Added item: {feature['id']}")
78-
elif resp.status_code == 409:
79-
click.echo(f"Status code: {resp.status_code}")
80-
click.echo(f"Item: {feature['id']} already exists")
81-
except requests.ConnectionError:
82-
click.secho("Failed to connect", fg="red", err=True)
83-
84-
85-
def load_items_bulk_insert(base_url, collection_id, feature_collection, data_dir):
86-
"""Load STAC items into the database via bulk insert."""
87-
try:
88-
for i, _ in enumerate(feature_collection["features"]):
89-
feature_collection["features"][i]["collection"] = collection_id
90-
resp = requests.post(
91-
f"{base_url}/collections/{collection_id}/items", json=feature_collection
92-
)
77+
feature["collection"] = collection_id
78+
resp = client.post(f"/collections/{collection_id}/items", json=feature)
9379
if resp.status_code == 200:
9480
click.echo(f"Status code: {resp.status_code}")
95-
click.echo("Bulk inserted items successfully.")
96-
elif resp.status_code == 204:
97-
click.echo(f"Status code: {resp.status_code}")
98-
click.echo("Bulk update successful, no content returned.")
81+
click.echo(f"Added item: {feature['id']}")
9982
elif resp.status_code == 409:
10083
click.echo(f"Status code: {resp.status_code}")
101-
click.echo("Conflict detected, some items might already exist.")
102-
except requests.ConnectionError:
103-
click.secho("Failed to connect", fg="red", err=True)
84+
click.echo(f"Item: {feature['id']} already exists")
85+
86+
87+
def load_items_bulk_insert(
88+
client: Client, collection_id: str, feature_collection: dict[str, Any]
89+
) -> None:
90+
"""Load STAC items into the database via bulk insert."""
91+
for feature in feature_collection["features"]:
92+
feature["collection"] = collection_id
93+
resp = client.post(f"/collections/{collection_id}/items", json=feature_collection)
94+
if resp.status_code == 200:
95+
click.echo(f"Status code: {resp.status_code}")
96+
click.echo("Bulk inserted items successfully.")
97+
elif resp.status_code == 204:
98+
click.echo(f"Status code: {resp.status_code}")
99+
click.echo("Bulk update successful, no content returned.")
100+
elif resp.status_code == 409:
101+
click.echo(f"Status code: {resp.status_code}")
102+
click.echo("Conflict detected, some items might already exist.")
104103

105104

106105
@click.command()
@@ -117,9 +116,10 @@ def load_items_bulk_insert(base_url, collection_id, feature_collection, data_dir
117116
default="sample_data/",
118117
help="Directory containing collection.json and feature collection file",
119118
)
120-
def main(base_url, collection_id, use_bulk, data_dir):
119+
def main(base_url: str, collection_id: str, use_bulk: bool, data_dir: str) -> None:
121120
"""Load STAC items into the database."""
122-
load_items(base_url, collection_id, use_bulk, data_dir)
121+
with Client(base_url=base_url) as client:
122+
load_items(client, collection_id, use_bulk, data_dir)
123123

124124

125125
if __name__ == "__main__":

examples/auth/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ limited permissions to specific read-only endpoints.
123123
{"path": "/collections/{collection_id}", "method": ["GET"]},
124124
{"path": "/collections/{collection_id}/items", "method": ["GET"]},
125125
{"path": "/queryables", "method": ["GET"]},
126-
{"path": "/queryables/collections/{collection_id}/queryables", "method": ["GET"]},
126+
{"path": "/collections/{collection_id}/queryables", "method": ["GET"]},
127127
{"path": "/_mgmt/ping", "method": ["GET"]}
128128
],
129129
"dependencies": [

examples/auth/compose.basic_auth.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ services:
2121
- ES_USE_SSL=false
2222
- ES_VERIFY_CERTS=false
2323
- BACKEND=elasticsearch
24-
- STAC_FASTAPI_ROUTE_DEPENDENCIES=[{"routes":[{"method":"*","path":"*"}],"dependencies":[{"method":"stac_fastapi.core.basic_auth.BasicAuth","kwargs":{"credentials":[{"username":"admin","password":"admin"}]}}]},{"routes":[{"path":"/","method":["GET"]},{"path":"/conformance","method":["GET"]},{"path":"/collections/{collection_id}/items/{item_id}","method":["GET"]},{"path":"/search","method":["GET","POST"]},{"path":"/collections","method":["GET"]},{"path":"/collections/{collection_id}","method":["GET"]},{"path":"/collections/{collection_id}/items","method":["GET"]},{"path":"/queryables","method":["GET"]},{"path":"/queryables/collections/{collection_id}/queryables","method":["GET"]},{"path":"/_mgmt/ping","method":["GET"]}],"dependencies":[{"method":"stac_fastapi.core.basic_auth.BasicAuth","kwargs":{"credentials":[{"username":"reader","password":"reader"}]}}]}]
24+
- STAC_FASTAPI_ROUTE_DEPENDENCIES=[{"routes":[{"method":"*","path":"*"}],"dependencies":[{"method":"stac_fastapi.core.basic_auth.BasicAuth","kwargs":{"credentials":[{"username":"admin","password":"admin"}]}}]},{"routes":[{"path":"/","method":["GET"]},{"path":"/conformance","method":["GET"]},{"path":"/collections/{collection_id}/items/{item_id}","method":["GET"]},{"path":"/search","method":["GET","POST"]},{"path":"/collections","method":["GET"]},{"path":"/collections/{collection_id}","method":["GET"]},{"path":"/collections/{collection_id}/items","method":["GET"]},{"path":"/queryables","method":["GET"]},{"path":"/collections/{collection_id}/queryables","method":["GET"]},{"path":"/_mgmt/ping","method":["GET"]}],"dependencies":[{"method":"stac_fastapi.core.basic_auth.BasicAuth","kwargs":{"credentials":[{"username":"reader","password":"reader"}]}}]}]
2525
ports:
2626
- "8080:8080"
2727
volumes:
@@ -55,7 +55,7 @@ services:
5555
- ES_USE_SSL=false
5656
- ES_VERIFY_CERTS=false
5757
- BACKEND=opensearch
58-
- STAC_FASTAPI_ROUTE_DEPENDENCIES=[{"routes":[{"method":"*","path":"*"}],"dependencies":[{"method":"stac_fastapi.core.basic_auth.BasicAuth","kwargs":{"credentials":[{"username":"admin","password":"admin"}]}}]},{"routes":[{"path":"/","method":["GET"]},{"path":"/conformance","method":["GET"]},{"path":"/collections/{collection_id}/items/{item_id}","method":["GET"]},{"path":"/search","method":["GET","POST"]},{"path":"/collections","method":["GET"]},{"path":"/collections/{collection_id}","method":["GET"]},{"path":"/collections/{collection_id}/items","method":["GET"]},{"path":"/queryables","method":["GET"]},{"path":"/queryables/collections/{collection_id}/queryables","method":["GET"]},{"path":"/_mgmt/ping","method":["GET"]}],"dependencies":[{"method":"stac_fastapi.core.basic_auth.BasicAuth","kwargs":{"credentials":[{"username":"reader","password":"reader"}]}}]}]
58+
- STAC_FASTAPI_ROUTE_DEPENDENCIES=[{"routes":[{"method":"*","path":"*"}],"dependencies":[{"method":"stac_fastapi.core.basic_auth.BasicAuth","kwargs":{"credentials":[{"username":"admin","password":"admin"}]}}]},{"routes":[{"path":"/","method":["GET"]},{"path":"/conformance","method":["GET"]},{"path":"/collections/{collection_id}/items/{item_id}","method":["GET"]},{"path":"/search","method":["GET","POST"]},{"path":"/collections","method":["GET"]},{"path":"/collections/{collection_id}","method":["GET"]},{"path":"/collections/{collection_id}/items","method":["GET"]},{"path":"/queryables","method":["GET"]},{"path":"/collections/{collection_id}/queryables","method":["GET"]},{"path":"/_mgmt/ping","method":["GET"]}],"dependencies":[{"method":"stac_fastapi.core.basic_auth.BasicAuth","kwargs":{"credentials":[{"username":"reader","password":"reader"}]}}]}]
5959
ports:
6060
- "8082:8082"
6161
volumes:

stac_fastapi/core/stac_fastapi/core/base_database_logic.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ async def delete_item(
6060
"""Delete an item from the database."""
6161
pass
6262

63+
@abc.abstractmethod
64+
async def get_items_mapping(self, collection_id: str) -> Dict[str, Dict[str, Any]]:
65+
"""Get the mapping for the items in the collection."""
66+
pass
67+
68+
@abc.abstractmethod
69+
async def get_items_unique_values(
70+
self, collection_id: str, field_names: Iterable[str], *, limit: int = ...
71+
) -> Dict[str, List[str]]:
72+
"""Get the unique values for the given fields in the collection."""
73+
pass
74+
6375
@abc.abstractmethod
6476
async def create_collection(self, collection: Dict, refresh: bool = False) -> None:
6577
"""Create a collection in the database."""

stac_fastapi/core/stac_fastapi/core/extensions/filter.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,17 @@
6060
"maximum": 100,
6161
},
6262
}
63+
"""Queryables that are present in all collections."""
64+
65+
OPTIONAL_QUERYABLES: Dict[str, Dict[str, Any]] = {
66+
"platform": {
67+
"$enum": True,
68+
"description": "Satellite platform identifier",
69+
},
70+
}
71+
"""Queryables that are present in some collections."""
72+
73+
ALL_QUERYABLES: Dict[str, Dict[str, Any]] = DEFAULT_QUERYABLES | OPTIONAL_QUERYABLES
6374

6475

6576
class LogicalOp(str, Enum):

stac_fastapi/elasticsearch/setup.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
"pytest-cov~=4.0.0",
2020
"pytest-asyncio~=0.21.0",
2121
"pre-commit~=3.0.0",
22-
"requests>=2.32.0,<3.0.0",
2322
"ciso8601~=2.3.0",
2423
"httpx>=0.24.0,<0.28.0",
2524
],

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
TokenPaginationExtension,
3838
TransactionExtension,
3939
)
40+
from stac_fastapi.extensions.core.filter import FilterConformanceClasses
4041
from stac_fastapi.extensions.third_party import BulkTransactionExtension
4142
from stac_fastapi.sfeos_helpers.aggregation import EsAsyncBaseAggregationClient
4243
from stac_fastapi.sfeos_helpers.filter import EsAsyncBaseFiltersClient
@@ -56,7 +57,7 @@
5657
client=EsAsyncBaseFiltersClient(database=database_logic)
5758
)
5859
filter_extension.conformance_classes.append(
59-
"http://www.opengis.net/spec/cql2/1.0/conf/advanced-comparison-operators"
60+
FilterConformanceClasses.ADVANCED_COMPARISON_OPERATORS
6061
)
6162

6263
aggregation_extension = AggregationExtension(
@@ -103,22 +104,24 @@
103104

104105
post_request_model = create_post_request_model(search_extensions)
105106

106-
api = StacApi(
107-
title=os.getenv("STAC_FASTAPI_TITLE", "stac-fastapi-elasticsearch"),
108-
description=os.getenv("STAC_FASTAPI_DESCRIPTION", "stac-fastapi-elasticsearch"),
109-
api_version=os.getenv("STAC_FASTAPI_VERSION", "5.0.0a1"),
110-
settings=settings,
111-
extensions=extensions,
112-
client=CoreClient(
107+
app_config = {
108+
"title": os.getenv("STAC_FASTAPI_TITLE", "stac-fastapi-elasticsearch"),
109+
"description": os.getenv("STAC_FASTAPI_DESCRIPTION", "stac-fastapi-elasticsearch"),
110+
"api_version": os.getenv("STAC_FASTAPI_VERSION", "5.0.0a1"),
111+
"settings": settings,
112+
"extensions": extensions,
113+
"client": CoreClient(
113114
database=database_logic,
114115
session=session,
115116
post_request_model=post_request_model,
116117
landing_page_id=os.getenv("STAC_FASTAPI_LANDING_PAGE_ID", "stac-fastapi"),
117118
),
118-
search_get_request_model=create_get_request_model(search_extensions),
119-
search_post_request_model=post_request_model,
120-
route_dependencies=get_route_dependencies(),
121-
)
119+
"search_get_request_model": create_get_request_model(search_extensions),
120+
"search_post_request_model": post_request_model,
121+
"route_dependencies": get_route_dependencies(),
122+
}
123+
124+
api = StacApi(**app_config)
122125

123126

124127
@asynccontextmanager

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1038,6 +1038,37 @@ async def get_items_mapping(self, collection_id: str) -> Dict[str, Any]:
10381038
except ESNotFoundError:
10391039
raise NotFoundError(f"Mapping for index {index_name} not found")
10401040

1041+
async def get_items_unique_values(
1042+
self, collection_id: str, field_names: Iterable[str], *, limit: int = 100
1043+
) -> Dict[str, List[str]]:
1044+
"""Get the unique values for the given fields in the collection."""
1045+
limit_plus_one = limit + 1
1046+
index_name = index_alias_by_collection_id(collection_id)
1047+
1048+
query = await self.client.search(
1049+
index=index_name,
1050+
body={
1051+
"size": 0,
1052+
"aggs": {
1053+
field: {"terms": {"field": field, "size": limit_plus_one}}
1054+
for field in field_names
1055+
},
1056+
},
1057+
)
1058+
1059+
result: Dict[str, List[str]] = {}
1060+
for field, agg in query["aggregations"].items():
1061+
if len(agg["buckets"]) > limit:
1062+
logger.warning(
1063+
"Skipping enum field %s: exceeds limit of %d unique values. "
1064+
"Consider excluding this field from enumeration or increase the limit.",
1065+
field,
1066+
limit,
1067+
)
1068+
continue
1069+
result[field] = [bucket["key"] for bucket in agg["buckets"]]
1070+
return result
1071+
10411072
async def create_collection(self, collection: Collection, **kwargs: Any):
10421073
"""Create a single collection in the database.
10431074

stac_fastapi/opensearch/setup.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
"pytest-cov~=4.0.0",
2121
"pytest-asyncio~=0.21.0",
2222
"pre-commit~=3.0.0",
23-
"requests>=2.32.0,<3.0.0",
2423
"ciso8601~=2.3.0",
2524
"httpx>=0.24.0,<0.28.0",
2625
],

0 commit comments

Comments
 (0)