Skip to content

Commit 4843f28

Browse files
committed
Implement dynamiclly generated queryables
1 parent 38539f1 commit 4843f28

File tree

5 files changed

+174
-55
lines changed

5 files changed

+174
-55
lines changed

stac_fastapi/core/stac_fastapi/core/core.py

Lines changed: 128 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""Core client."""
22

33
import logging
4+
from collections import deque
45
from datetime import datetime as datetime_type
56
from datetime import timezone
67
from enum import Enum
7-
from typing import Any, Dict, List, Optional, Set, Type, Union
8+
from typing import Any, Dict, List, Literal, Optional, Set, Type, Union
89
from urllib.parse import unquote_plus, urljoin
910

1011
import attr
@@ -905,11 +906,81 @@ def bulk_item_insert(
905906
return f"Successfully added {len(processed_items)} Items."
906907

907908

909+
_DEFAULT_QUERYABLES = {
910+
"id": {
911+
"description": "ID",
912+
"$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/definitions/core/allOf/2/properties/id",
913+
},
914+
"collection": {
915+
"description": "Collection",
916+
"$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/definitions/core/allOf/2/then/properties/collection",
917+
},
918+
"geometry": {
919+
"description": "Geometry",
920+
"$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/definitions/core/allOf/1/oneOf/0/properties/geometry",
921+
},
922+
"datetime": {
923+
"description": "Acquisition Timestamp",
924+
"$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/datetime.json#/properties/datetime",
925+
},
926+
"created": {
927+
"description": "Creation Timestamp",
928+
"$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/datetime.json#/properties/created",
929+
},
930+
"updated": {
931+
"description": "Creation Timestamp",
932+
"$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/datetime.json#/properties/updated",
933+
},
934+
"cloud_cover": {
935+
"description": "Cloud Cover",
936+
"$ref": "https://stac-extensions.github.io/eo/v1.0.0/schema.json#/definitions/fields/properties/eo:cloud_cover",
937+
},
938+
"cloud_shadow_percentage": {
939+
"title": "Cloud Shadow Percentage",
940+
"description": "Cloud Shadow Percentage",
941+
"type": "number",
942+
"minimum": 0,
943+
"maximum": 100,
944+
},
945+
"nodata_pixel_percentage": {
946+
"title": "No Data Pixel Percentage",
947+
"description": "No Data Pixel Percentage",
948+
"type": "number",
949+
"minimum": 0,
950+
"maximum": 100,
951+
},
952+
}
953+
954+
_ES_MAPPING_TYPE_TO_JSON: Dict[
955+
str, Literal["string", "number", "boolean", "object", "array", "null"]
956+
] = {
957+
"date": "string",
958+
"date_nanos": "string",
959+
"keyword": "string",
960+
"match_only_text": "string",
961+
"text": "string",
962+
"wildcard": "string",
963+
"byte": "number",
964+
"double": "number",
965+
"float": "number",
966+
"half_float": "number",
967+
"long": "number",
968+
"scaled_float": "number",
969+
"short": "number",
970+
"token_count": "number",
971+
"unsigned_long": "number",
972+
"geo_point": "object",
973+
"geo_shape": "object",
974+
"nested": "array",
975+
}
976+
977+
908978
@attr.s
909979
class EsAsyncBaseFiltersClient(AsyncBaseFiltersClient):
910980
"""Defines a pattern for implementing the STAC filter extension."""
911981

912-
# todo: use the ES _mapping endpoint to dynamically find what fields exist
982+
database: BaseDatabaseLogic = attr.ib()
983+
913984
async def get_queryables(
914985
self, collection_id: Optional[str] = None, **kwargs
915986
) -> Dict[str, Any]:
@@ -930,55 +1001,63 @@ async def get_queryables(
9301001
Returns:
9311002
Dict[str, Any]: A dictionary containing the queryables for the given collection.
9321003
"""
933-
return {
1004+
queryables: Dict[str, Any] = {
9341005
"$schema": "https://json-schema.org/draft/2019-09/schema",
9351006
"$id": "https://stac-api.example.com/queryables",
9361007
"type": "object",
937-
"title": "Queryables for Example STAC API",
938-
"description": "Queryable names for the example STAC API Item Search filter.",
939-
"properties": {
940-
"id": {
941-
"description": "ID",
942-
"$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/definitions/core/allOf/2/properties/id",
943-
},
944-
"collection": {
945-
"description": "Collection",
946-
"$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/definitions/core/allOf/2/then/properties/collection",
947-
},
948-
"geometry": {
949-
"description": "Geometry",
950-
"$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/definitions/core/allOf/1/oneOf/0/properties/geometry",
951-
},
952-
"datetime": {
953-
"description": "Acquisition Timestamp",
954-
"$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/datetime.json#/properties/datetime",
955-
},
956-
"created": {
957-
"description": "Creation Timestamp",
958-
"$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/datetime.json#/properties/created",
959-
},
960-
"updated": {
961-
"description": "Creation Timestamp",
962-
"$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/datetime.json#/properties/updated",
963-
},
964-
"cloud_cover": {
965-
"description": "Cloud Cover",
966-
"$ref": "https://stac-extensions.github.io/eo/v1.0.0/schema.json#/definitions/fields/properties/eo:cloud_cover",
967-
},
968-
"cloud_shadow_percentage": {
969-
"description": "Cloud Shadow Percentage",
970-
"title": "Cloud Shadow Percentage",
971-
"type": "number",
972-
"minimum": 0,
973-
"maximum": 100,
974-
},
975-
"nodata_pixel_percentage": {
976-
"description": "No Data Pixel Percentage",
977-
"title": "No Data Pixel Percentage",
978-
"type": "number",
979-
"minimum": 0,
980-
"maximum": 100,
981-
},
982-
},
1008+
"title": "Queryables for STAC API",
1009+
"description": "Queryable names for the STAC API Item Search filter.",
1010+
"properties": _DEFAULT_QUERYABLES,
9831011
"additionalProperties": True,
9841012
}
1013+
if not collection_id:
1014+
return queryables
1015+
1016+
properties = {}
1017+
queryables.update(
1018+
{
1019+
"properties": properties,
1020+
"additionalProperties": False,
1021+
}
1022+
)
1023+
1024+
mapping_data = await self.database.get_items_mapping(collection_id)
1025+
mapping_properties = next(iter(mapping_data.values()))["mappings"]["properties"]
1026+
stack = deque(mapping_properties.items())
1027+
1028+
while stack:
1029+
field_name, field_def = stack.popleft()
1030+
1031+
# Iterate over nested fields
1032+
field_properties = field_def.get("properties")
1033+
if field_properties:
1034+
# Fields in Item Properties should be exposed with their un-prefixed names,
1035+
# and not require expressions to prefix them with properties,
1036+
# e.g., eo:cloud_cover instead of properties.eo:cloud_cover.
1037+
if field_name == "properties":
1038+
stack.extend(field_properties.items())
1039+
else:
1040+
stack.extend(
1041+
(f"{field_name}.{k}", v) for k, v in field_properties.items()
1042+
)
1043+
1044+
# Skip non-indexed or disabled fields
1045+
field_type = field_def.get("type")
1046+
if not field_type or not field_def.get("enabled", True):
1047+
continue
1048+
1049+
# Generate field properties
1050+
field_result = _DEFAULT_QUERYABLES.get(field_name, {})
1051+
properties[field_name] = field_result
1052+
1053+
field_name_human = field_name.replace("_", " ").title()
1054+
field_result.setdefault("title", field_name_human)
1055+
field_result.setdefault("description", field_name_human)
1056+
1057+
field_type_json = _ES_MAPPING_TYPE_TO_JSON.get(field_type, field_type)
1058+
field_result.setdefault("type", field_type_json)
1059+
1060+
if field_type in {"date", "date_nanos"}:
1061+
field_result.setdefault("format", "date-time")
1062+
1063+
return queryables

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,15 @@
3939
settings = ElasticsearchSettings()
4040
session = Session.create_from_settings(settings)
4141

42-
filter_extension = FilterExtension(client=EsAsyncBaseFiltersClient())
42+
database_logic = DatabaseLogic()
43+
44+
filter_extension = FilterExtension(
45+
client=EsAsyncBaseFiltersClient(database=database_logic)
46+
)
4347
filter_extension.conformance_classes.append(
4448
"http://www.opengis.net/spec/cql2/1.0/conf/advanced-comparison-operators"
4549
)
4650

47-
database_logic = DatabaseLogic()
48-
4951
aggregation_extension = AggregationExtension(
5052
client=EsAsyncAggregationClient(
5153
database=database_logic, session=session, settings=settings

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,24 @@ async def delete_item(
725725
f"Item {item_id} in collection {collection_id} not found"
726726
)
727727

728+
async def get_items_mapping(self, collection_id: str) -> Dict[str, Any]:
729+
"""Get the mapping for the specified collection's items index.
730+
731+
Args:
732+
collection_id (str): The ID of the collection to get items mapping for.
733+
734+
Returns:
735+
Dict[str, Any]: The mapping information.
736+
"""
737+
index_name = index_alias_by_collection_id(collection_id)
738+
try:
739+
mapping = await self.client.indices.get_mapping(
740+
index=index_name, allow_no_indices=False
741+
)
742+
return mapping.body
743+
except exceptions.NotFoundError:
744+
raise NotFoundError(f"Mapping for index {index_name} not found")
745+
728746
async def create_collection(self, collection: Collection, refresh: bool = False):
729747
"""Create a single collection in the database.
730748

stac_fastapi/opensearch/stac_fastapi/opensearch/app.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,15 @@
3939
settings = OpensearchSettings()
4040
session = Session.create_from_settings(settings)
4141

42-
filter_extension = FilterExtension(client=EsAsyncBaseFiltersClient())
42+
database_logic = DatabaseLogic()
43+
44+
filter_extension = FilterExtension(
45+
client=EsAsyncBaseFiltersClient(database=database_logic)
46+
)
4347
filter_extension.conformance_classes.append(
4448
"http://www.opengis.net/spec/cql2/1.0/conf/advanced-comparison-operators"
4549
)
4650

47-
database_logic = DatabaseLogic()
48-
4951
aggregation_extension = AggregationExtension(
5052
client=EsAsyncAggregationClient(
5153
database=database_logic, session=session, settings=settings

stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,24 @@ async def delete_item(
757757
f"Item {item_id} in collection {collection_id} not found"
758758
)
759759

760+
async def get_items_mapping(self, collection_id: str) -> Dict[str, Any]:
761+
"""Get the mapping for the specified collection's items index.
762+
763+
Args:
764+
collection_id (str): The ID of the collection to get items mapping for.
765+
766+
Returns:
767+
Dict[str, Any]: The mapping information.
768+
"""
769+
index_name = index_alias_by_collection_id(collection_id)
770+
try:
771+
mapping = await self.client.indices.get_mapping(
772+
index=index_name, params={"allow_no_indices": False}
773+
)
774+
return mapping
775+
except exceptions.NotFoundError:
776+
raise NotFoundError(f"Mapping for index {index_name} not found")
777+
760778
async def create_collection(self, collection: Collection, refresh: bool = False):
761779
"""Create a single collection in the database.
762780

0 commit comments

Comments
 (0)