Skip to content

Commit a46665e

Browse files
author
Andrzej Pijanowski
committed
feat: move queryables cache and validation logic to core
1 parent 6cdb67c commit a46665e

File tree

6 files changed

+40
-112
lines changed

6 files changed

+40
-112
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,7 @@ You can customize additional settings in your `.env` file:
367367
| `STAC_INDEX_ASSETS` | Controls if Assets are indexed when added to Elasticsearch/Opensearch. This allows asset fields to be included in search queries. | `false` | Optional |
368368
| `USE_DATETIME` | Configures the datetime search behavior in SFEOS. When enabled, searches both datetime field and falls back to start_datetime/end_datetime range for items with null datetime. When disabled, searches only by start_datetime/end_datetime range. | `true` | Optional |
369369
| `USE_DATETIME_NANOS` | Enables nanosecond precision handling for `datetime` field searches as per the `date_nanos` type. When `False`, it uses 3 millisecond precision as per the type `date`. | `true` | Optional |
370-
| `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 |
370+
| `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. If `VALIDATE_QUERYABLES` is enabled, these fields will also be considered invalid for filtering. | None | Optional |
371371
| `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 |
372372
| `VALIDATE_QUERYABLES` | Enable validation of query parameters against the collection's queryables. If set to `true`, the API will reject queries containing fields that are not defined in the collection's queryables. | `false` | Optional |
373373
| `QUERYABLES_CACHE_TTL` | Time-to-live (in seconds) for the queryables cache. Used when `VALIDATE_QUERYABLES` is enabled. | `3600` | Optional |

stac_fastapi/core/stac_fastapi/core/base_database_logic.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,8 @@ async def delete_collection(
138138
) -> None:
139139
"""Delete a collection from the database."""
140140
pass
141+
142+
@abc.abstractmethod
143+
async def get_queryables_mapping(self, collection_id: str = "*") -> Dict[str, Any]:
144+
"""Retrieve mapping of Queryables for search."""
145+
pass

stac_fastapi/core/stac_fastapi/core/core.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
from stac_fastapi.core.base_settings import ApiBaseSettings
2525
from stac_fastapi.core.datetime_utils import format_datetime_range
2626
from stac_fastapi.core.models.links import PagingLinks
27+
from stac_fastapi.core.queryables import (
28+
QueryablesCache,
29+
get_properties_from_cql2_filter,
30+
)
2731
from stac_fastapi.core.redis_utils import redis_pagination_links
2832
from stac_fastapi.core.serializers import CollectionSerializer, ItemSerializer
2933
from stac_fastapi.core.session import Session
@@ -39,11 +43,6 @@
3943
BulkTransactionMethod,
4044
Items,
4145
)
42-
from stac_fastapi.sfeos_helpers.queryables import (
43-
get_properties_from_cql2_filter,
44-
initialize_queryables_cache,
45-
validate_queryables,
46-
)
4746
from stac_fastapi.types import stac as stac_types
4847
from stac_fastapi.types.conformance import BASE_CONFORMANCE_CLASSES
4948
from stac_fastapi.types.core import AsyncBaseCoreClient
@@ -95,7 +94,7 @@ class CoreClient(AsyncBaseCoreClient):
9594

9695
def __attrs_post_init__(self):
9796
"""Initialize the queryables cache."""
98-
initialize_queryables_cache(self.database)
97+
self.queryables_cache = QueryablesCache(self.database)
9998

10099
def _landing_page(
101100
self,
@@ -826,7 +825,7 @@ async def post_search(
826825

827826
if hasattr(search_request, "query") and getattr(search_request, "query"):
828827
query_fields = set(getattr(search_request, "query").keys())
829-
await validate_queryables(query_fields)
828+
await self.queryables_cache.validate(query_fields)
830829
for field_name, expr in getattr(search_request, "query").items():
831830
field = "properties__" + field_name
832831
for op, value in expr.items():
@@ -846,7 +845,7 @@ async def post_search(
846845
if cql2_filter is not None:
847846
try:
848847
query_fields = get_properties_from_cql2_filter(cql2_filter)
849-
await validate_queryables(query_fields)
848+
await self.queryables_cache.validate(query_fields)
850849
search = await self.database.apply_cql2_filter(search, cql2_filter)
851850
except HTTPException:
852851
raise

stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/queryables.py renamed to stac_fastapi/core/stac_fastapi/core/queryables.py

Lines changed: 1 addition & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,10 @@
33
import asyncio
44
import os
55
import time
6-
from typing import Any, Dict, List, Optional, Set
6+
from typing import Any, Dict, List, Set
77

88
from fastapi import HTTPException
99

10-
from stac_fastapi.core.base_database_logic import BaseDatabaseLogic
11-
1210

1311
class QueryablesCache:
1412
"""A thread-safe, time-based cache for queryable properties."""
@@ -99,40 +97,6 @@ async def validate(self, fields: Set[str]) -> None:
9997
)
10098

10199

102-
_queryables_cache_instance: Optional[QueryablesCache] = None
103-
104-
105-
def initialize_queryables_cache(database_logic: BaseDatabaseLogic):
106-
"""
107-
Initialize the global queryables cache.
108-
109-
:param database_logic: An instance of DatabaseLogic.
110-
"""
111-
global _queryables_cache_instance
112-
if _queryables_cache_instance is None:
113-
_queryables_cache_instance = QueryablesCache(database_logic)
114-
115-
116-
async def all_queryables() -> Set[str]:
117-
"""Get all queryable properties from the cache."""
118-
if _queryables_cache_instance is None:
119-
raise Exception("Queryables cache not initialized.")
120-
return await _queryables_cache_instance.get_all_queryables()
121-
122-
123-
async def validate_queryables(fields: Set[str]) -> None:
124-
"""Validate if the provided fields are queryable."""
125-
if _queryables_cache_instance is None:
126-
return
127-
await _queryables_cache_instance.validate(fields)
128-
129-
130-
def reload_queryables_settings():
131-
"""Reload queryables settings from environment variables."""
132-
if _queryables_cache_instance:
133-
_queryables_cache_instance.reload_settings()
134-
135-
136100
def get_properties_from_cql2_filter(cql2_filter: Dict[str, Any]) -> Set[str]:
137101
"""Recursively extract property names from a CQL2 filter."""
138102
props: Set[str] = set()

stac_fastapi/tests/api/test_api_query_validation.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,34 @@
44

55
import pytest
66

7-
from stac_fastapi.sfeos_helpers.queryables import reload_queryables_settings
7+
if os.getenv("BACKEND", "elasticsearch").lower() == "opensearch":
8+
from stac_fastapi.opensearch.app import app_config
9+
else:
10+
from stac_fastapi.elasticsearch.app import app_config
11+
12+
13+
def get_core_client():
14+
if os.getenv("BACKEND", "elasticsearch").lower() == "opensearch":
15+
from stac_fastapi.opensearch.app import app_config
16+
else:
17+
from stac_fastapi.elasticsearch.app import app_config
18+
return app_config["client"]
19+
20+
21+
def reload_queryables_settings():
22+
client = get_core_client()
23+
if hasattr(client, "queryables_cache"):
24+
client.queryables_cache.reload_settings()
825

926

1027
@pytest.fixture(autouse=True)
1128
def enable_validation():
29+
30+
client = app_config["client"]
1231
with mock.patch.dict(os.environ, {"VALIDATE_QUERYABLES": "true"}):
13-
reload_queryables_settings()
32+
client.queryables_cache.reload_settings()
1433
yield
15-
reload_queryables_settings()
34+
client.queryables_cache.reload_settings()
1635

1736

1837
@pytest.mark.asyncio
@@ -72,6 +91,7 @@ async def test_validate_queryables_excluded(app_client, ctx):
7291
"""Test that excluded queryables are rejected when validation is enabled."""
7392

7493
excluded_field = "eo:cloud_cover"
94+
client = app_config["client"]
7595

7696
with mock.patch.dict(
7797
os.environ,
@@ -81,7 +101,7 @@ async def test_validate_queryables_excluded(app_client, ctx):
81101
"QUERYABLES_CACHE_TTL": "0",
82102
},
83103
):
84-
reload_queryables_settings()
104+
client.queryables_cache.reload_settings()
85105

86106
query = {"query": {excluded_field: {"lt": 10}}}
87107
resp = await app_client.post("/search", json=query)
@@ -93,4 +113,4 @@ async def test_validate_queryables_excluded(app_client, ctx):
93113
resp = await app_client.post("/search", json=query)
94114
assert resp.status_code == 200
95115

96-
reload_queryables_settings()
116+
client.queryables_cache.reload_settings()

stac_fastapi/tests/sfeos_helpers/test_queryables.py renamed to stac_fastapi/tests/core/test_queryables.py

Lines changed: 1 addition & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,9 @@
55
import pytest
66
from fastapi import HTTPException
77

8-
import stac_fastapi.sfeos_helpers.queryables as queryables_module
9-
from stac_fastapi.sfeos_helpers.queryables import (
8+
from stac_fastapi.core.queryables import (
109
QueryablesCache,
11-
all_queryables,
1210
get_properties_from_cql2_filter,
13-
initialize_queryables_cache,
14-
reload_queryables_settings,
15-
validate_queryables,
1611
)
1712

1813

@@ -102,61 +97,6 @@ async def test_validate_disabled(self, queryables_cache):
10297
await queryables_cache.validate({"invalid_prop"})
10398

10499

105-
class TestGlobalFunctions:
106-
@pytest.fixture(autouse=True)
107-
def reset_global_cache(self):
108-
original = queryables_module._queryables_cache_instance
109-
queryables_module._queryables_cache_instance = None
110-
yield
111-
112-
queryables_module._queryables_cache_instance = original
113-
114-
def test_initialize_queryables_cache(self):
115-
db_logic = MagicMock()
116-
initialize_queryables_cache(db_logic)
117-
assert queryables_module._queryables_cache_instance is not None
118-
assert queryables_module._queryables_cache_instance._db_logic == db_logic
119-
120-
@pytest.mark.asyncio
121-
async def test_all_queryables_not_initialized(self):
122-
with pytest.raises(Exception) as excinfo:
123-
await all_queryables()
124-
assert "Queryables cache not initialized" in str(excinfo.value)
125-
126-
@pytest.mark.asyncio
127-
async def test_all_queryables(self):
128-
db_logic = MagicMock()
129-
db_logic.get_queryables_mapping = AsyncMock(return_value={"p1": "t1"})
130-
initialize_queryables_cache(db_logic)
131-
132-
queryables_module._queryables_cache_instance.validation_enabled = True
133-
134-
res = await all_queryables()
135-
assert res == {"p1"}
136-
137-
@pytest.mark.asyncio
138-
async def test_validate_queryables(self):
139-
db_logic = MagicMock()
140-
db_logic.get_queryables_mapping = AsyncMock(return_value={"p1": "t1"})
141-
initialize_queryables_cache(db_logic)
142-
queryables_module._queryables_cache_instance.validation_enabled = True
143-
144-
await validate_queryables({"p1"})
145-
146-
with pytest.raises(HTTPException):
147-
await validate_queryables({"invalid"})
148-
149-
def test_reload_queryables_settings(self):
150-
db_logic = MagicMock()
151-
initialize_queryables_cache(db_logic)
152-
153-
with patch.dict(os.environ, {"VALIDATE_QUERYABLES": "false"}):
154-
reload_queryables_settings()
155-
assert (
156-
queryables_module._queryables_cache_instance.validation_enabled is False
157-
)
158-
159-
160100
def test_get_properties_from_cql2_filter():
161101
# Simple prop
162102
cql2 = {"op": "=", "args": [{"property": "prop1"}, "value"]}

0 commit comments

Comments
 (0)