Skip to content

Commit 81ee57d

Browse files
author
Andrzej Pijanowski
committed
feat: add STAC_FASTAPI_ES_MAPPINGS_FILE for file-based custom mappings configuration
1 parent 30c7e83 commit 81ee57d

File tree

4 files changed

+208
-2
lines changed

4 files changed

+208
-2
lines changed

CHANGELOG.md

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

1010
### Added
11+
- Added `STAC_FASTAPI_ES_MAPPINGS_FILE` environment variable to support file-based custom mappings configuration.
1112
- Added configuration-based support for extending Elasticsearch/OpenSearch index mappings via environment variables, allowing users to customize field mappings without code change through `STAC_FASTAPI_ES_CUSTOM_MAPPINGS` environment variable. Also added `STAC_FASTAPI_ES_DYNAMIC_MAPPING` variable to control dynamic mapping behavior. [#546](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/546)
1213

1314
### Changed

README.md

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ You can customize additional settings in your `.env` file:
371371
| `EXCLUDED_FROM_QUERYABLES` | Comma-separated list of fully qualified field names to exclude from the queryables endpoint and filtering. Use full paths like `properties.auth:schemes,properties.storage:schemes`. Excluded fields and their nested children will not be exposed in queryables. | None | Optional |
372372
| `EXCLUDED_FROM_ITEMS` | Specifies fields to exclude from STAC item responses. Supports comma-separated field names and dot notation for nested fields (e.g., `private_data,properties.confidential,assets.internal`). | `None` | Optional |
373373
| `STAC_FASTAPI_ES_CUSTOM_MAPPINGS` | JSON string of custom Elasticsearch/OpenSearch property mappings to merge with defaults. See [Custom Index Mappings](#custom-index-mappings). | `None` | Optional |
374+
| `STAC_FASTAPI_ES_MAPPINGS_FILE` | Path to a JSON file containing custom Elasticsearch/OpenSearch property mappings to merge with defaults. See [Custom Index Mappings](#custom-index-mappings). | `None` | Optional |
374375
| `STAC_FASTAPI_ES_DYNAMIC_MAPPING` | Controls dynamic mapping behavior for item indices. Values: `true` (default), `false`, or `strict`. See [Custom Index Mappings](#custom-index-mappings). | `true` | Optional |
375376

376377

@@ -709,11 +710,17 @@ SFEOS provides environment variables to customize Elasticsearch/OpenSearch index
709710
| Variable | Description | Default |
710711
|----------|-------------|---------|
711712
| `STAC_FASTAPI_ES_CUSTOM_MAPPINGS` | JSON string of property mappings to merge with defaults | None |
713+
| `STAC_FASTAPI_ES_MAPPINGS_FILE` | Path to a JSON file containing property mappings to merge with defaults | None |
712714
| `STAC_FASTAPI_ES_DYNAMIC_MAPPING` | Controls dynamic mapping: `true`, `false`, or `strict` | `true` |
713715

714-
### Custom Mappings (`STAC_FASTAPI_ES_CUSTOM_MAPPINGS`)
716+
### Custom Mappings
715717

716-
Accepts a JSON string with the same structure as the default ES mappings. The custom mappings are **recursively merged** with the defaults at the root level.
718+
You can customize the Elasticsearch/OpenSearch mappings by providing a JSON configuration. This can be done via:
719+
720+
1. `STAC_FASTAPI_ES_CUSTOM_MAPPINGS` environment variable (takes precedence)
721+
2. `STAC_FASTAPI_ES_MAPPINGS_FILE` environment variable (file path)
722+
723+
The configuration should have the same structure as the default ES mappings. The custom mappings are **recursively merged** with the defaults at the root level.
717724

718725
#### Merge Behavior
719726

@@ -806,6 +813,86 @@ export STAC_FASTAPI_ES_CUSTOM_MAPPINGS='{
806813
}'
807814
```
808815

816+
**Example - Using a mappings file (recommended for complex configurations):**
817+
818+
Instead of passing large JSON blobs via environment variables, you can use a file:
819+
820+
```bash
821+
# Create a mappings file
822+
cat > custom-mappings.json <<EOF
823+
{
824+
"properties": {
825+
"properties": {
826+
"properties": {
827+
"sar:frequency_band": {"type": "keyword"},
828+
"sar:center_frequency": {"type": "float"},
829+
"sar:polarizations": {"type": "keyword"},
830+
"sar:product_type": {"type": "keyword"},
831+
"eo:cloud_cover": {"type": "float"},
832+
"platform": {"type": "keyword"}
833+
}
834+
}
835+
}
836+
}
837+
EOF
838+
839+
# Reference the file
840+
export STAC_FASTAPI_ES_MAPPINGS_FILE=/path/to/custom-mappings.json
841+
```
842+
843+
In Docker Compose, you can mount the file:
844+
845+
```yaml
846+
services:
847+
app-elasticsearch:
848+
volumes:
849+
- ./custom-mappings.json:/app/mappings.json:ro
850+
environment:
851+
- STAC_FASTAPI_ES_MAPPINGS_FILE=/app/mappings.json
852+
```
853+
854+
In Kubernetes, use a ConfigMap:
855+
856+
```yaml
857+
apiVersion: v1
858+
kind: ConfigMap
859+
metadata:
860+
name: stac-mappings
861+
data:
862+
mappings.json: |
863+
{
864+
"properties": {
865+
"properties": {
866+
"properties": {
867+
"platform": {"type": "keyword"},
868+
"eo:cloud_cover": {"type": "float"}
869+
}
870+
}
871+
}
872+
}
873+
---
874+
apiVersion: apps/v1
875+
kind: Deployment
876+
spec:
877+
template:
878+
spec:
879+
containers:
880+
- name: stac-fastapi
881+
env:
882+
- name: STAC_FASTAPI_ES_MAPPINGS_FILE
883+
value: /etc/stac/mappings.json
884+
volumeMounts:
885+
- name: mappings
886+
mountPath: /etc/stac
887+
volumes:
888+
- name: mappings
889+
configMap:
890+
name: stac-mappings
891+
```
892+
893+
> [!TIP]
894+
> If both `STAC_FASTAPI_ES_CUSTOM_MAPPINGS` and `STAC_FASTAPI_ES_MAPPINGS_FILE` are set, the environment variable takes precedence, allowing quick overrides during testing or troubleshooting.
895+
809896
### Dynamic Mapping Control (`STAC_FASTAPI_ES_DYNAMIC_MAPPING`)
810897

811898
Controls how Elasticsearch/OpenSearch handles fields not defined in the mapping:

stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/mappings.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,18 @@ def get_items_mappings(
138138
if custom_mappings is not None
139139
else os.getenv("STAC_FASTAPI_ES_CUSTOM_MAPPINGS")
140140
)
141+
142+
if custom_config is None:
143+
mappings_file = os.getenv("STAC_FASTAPI_ES_MAPPINGS_FILE")
144+
if mappings_file:
145+
try:
146+
with open(mappings_file, "r") as f:
147+
custom_config = f.read()
148+
except Exception as e:
149+
logger.error(
150+
f"Failed to read STAC_FASTAPI_ES_MAPPINGS_FILE at {mappings_file}: {e}"
151+
)
152+
141153
apply_custom_mappings(mappings, custom_config)
142154

143155
return mappings
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import json
2+
3+
from stac_fastapi.sfeos_helpers.mappings import get_items_mappings
4+
5+
6+
class TestMappingsFile:
7+
def test_mappings_file_applied(self, monkeypatch, tmp_path):
8+
"""Test that mappings are read from file when env var is set."""
9+
custom_mappings = {
10+
"properties": {"properties": {"file_field": {"type": "keyword"}}}
11+
}
12+
mappings_file = tmp_path / "mappings.json"
13+
mappings_file.write_text(json.dumps(custom_mappings))
14+
15+
monkeypatch.setenv("STAC_FASTAPI_ES_MAPPINGS_FILE", str(mappings_file))
16+
monkeypatch.delenv("STAC_FASTAPI_ES_CUSTOM_MAPPINGS", raising=False)
17+
18+
mappings = get_items_mappings()
19+
20+
assert mappings["properties"]["properties"]["file_field"] == {"type": "keyword"}
21+
22+
def test_env_var_precedence(self, monkeypatch, tmp_path):
23+
"""Test that STAC_FASTAPI_ES_CUSTOM_MAPPINGS takes precedence over file."""
24+
file_mappings = {
25+
"properties": {"properties": {"shared_field": {"type": "keyword"}}}
26+
}
27+
mappings_file = tmp_path / "mappings.json"
28+
mappings_file.write_text(json.dumps(file_mappings))
29+
30+
env_mappings = {
31+
"properties": {"properties": {"shared_field": {"type": "text"}}}
32+
}
33+
34+
monkeypatch.setenv("STAC_FASTAPI_ES_MAPPINGS_FILE", str(mappings_file))
35+
monkeypatch.setenv("STAC_FASTAPI_ES_CUSTOM_MAPPINGS", json.dumps(env_mappings))
36+
37+
mappings = get_items_mappings()
38+
39+
assert mappings["properties"]["properties"]["shared_field"] == {"type": "text"}
40+
41+
def test_missing_file_handled_gracefully(self, monkeypatch, caplog):
42+
"""Test that missing file is logged and ignored."""
43+
monkeypatch.setenv("STAC_FASTAPI_ES_MAPPINGS_FILE", "/non/existent/file.json")
44+
monkeypatch.delenv("STAC_FASTAPI_ES_CUSTOM_MAPPINGS", raising=False)
45+
46+
get_items_mappings()
47+
48+
assert "Failed to read STAC_FASTAPI_ES_MAPPINGS_FILE" in caplog.text
49+
50+
def test_invalid_json_in_file(self, monkeypatch, tmp_path, caplog):
51+
"""Test that invalid JSON in file is logged and ignored."""
52+
mappings_file = tmp_path / "invalid.json"
53+
mappings_file.write_text("{this is not valid json}")
54+
55+
monkeypatch.setenv("STAC_FASTAPI_ES_MAPPINGS_FILE", str(mappings_file))
56+
monkeypatch.delenv("STAC_FASTAPI_ES_CUSTOM_MAPPINGS", raising=False)
57+
58+
get_items_mappings()
59+
60+
assert "Failed to parse STAC_FASTAPI_ES_CUSTOM_MAPPINGS JSON" in caplog.text
61+
62+
def test_file_and_env_var_both_set(self, monkeypatch, tmp_path):
63+
"""Test that env var completely overrides file when both are set."""
64+
file_mappings = {
65+
"properties": {
66+
"properties": {
67+
"file_only_field": {"type": "keyword"},
68+
"shared_field": {"type": "text"},
69+
}
70+
}
71+
}
72+
mappings_file = tmp_path / "mappings.json"
73+
mappings_file.write_text(json.dumps(file_mappings))
74+
75+
env_mappings = {
76+
"properties": {
77+
"properties": {
78+
"env_only_field": {"type": "keyword"},
79+
"shared_field": {"type": "integer"},
80+
}
81+
}
82+
}
83+
84+
monkeypatch.setenv("STAC_FASTAPI_ES_MAPPINGS_FILE", str(mappings_file))
85+
monkeypatch.setenv("STAC_FASTAPI_ES_CUSTOM_MAPPINGS", json.dumps(env_mappings))
86+
87+
mappings = get_items_mappings()
88+
89+
# Only env var fields should be present
90+
assert "env_only_field" in mappings["properties"]["properties"]
91+
assert "file_only_field" not in mappings["properties"]["properties"]
92+
assert mappings["properties"]["properties"]["shared_field"] == {
93+
"type": "integer"
94+
}
95+
96+
def test_empty_file_handled_gracefully(self, monkeypatch, tmp_path):
97+
"""Test that empty file is handled without error."""
98+
mappings_file = tmp_path / "empty.json"
99+
mappings_file.write_text("")
100+
101+
monkeypatch.setenv("STAC_FASTAPI_ES_MAPPINGS_FILE", str(mappings_file))
102+
monkeypatch.delenv("STAC_FASTAPI_ES_CUSTOM_MAPPINGS", raising=False)
103+
104+
# Should not raise, just use default mappings
105+
mappings = get_items_mappings()
106+
assert "properties" in mappings

0 commit comments

Comments
 (0)