stac-fastapi-elasticsearch 6.3.0__py3-none-any.whl → 6.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,6 +23,9 @@ from stac_fastapi.core.extensions.aggregation import (
23
23
  EsAggregationExtensionGetRequest,
24
24
  EsAggregationExtensionPostRequest,
25
25
  )
26
+ from stac_fastapi.core.extensions.collections_search import (
27
+ CollectionsSearchEndpointExtension,
28
+ )
26
29
  from stac_fastapi.core.extensions.fields import FieldsExtension
27
30
  from stac_fastapi.core.rate_limit import setup_rate_limit
28
31
  from stac_fastapi.core.route_dependencies import get_route_dependencies
@@ -37,6 +40,8 @@ from stac_fastapi.elasticsearch.database_logic import (
37
40
  from stac_fastapi.extensions.core import (
38
41
  AggregationExtension,
39
42
  CollectionSearchExtension,
43
+ CollectionSearchFilterExtension,
44
+ CollectionSearchPostExtension,
40
45
  FilterExtension,
41
46
  FreeTextExtension,
42
47
  SortExtension,
@@ -45,6 +50,7 @@ from stac_fastapi.extensions.core import (
45
50
  )
46
51
  from stac_fastapi.extensions.core.fields import FieldsConformanceClasses
47
52
  from stac_fastapi.extensions.core.filter import FilterConformanceClasses
53
+ from stac_fastapi.extensions.core.free_text import FreeTextConformanceClasses
48
54
  from stac_fastapi.extensions.core.query import QueryConformanceClasses
49
55
  from stac_fastapi.extensions.core.sort import SortConformanceClasses
50
56
  from stac_fastapi.extensions.third_party import BulkTransactionExtension
@@ -55,7 +61,15 @@ logging.basicConfig(level=logging.INFO)
55
61
  logger = logging.getLogger(__name__)
56
62
 
57
63
  TRANSACTIONS_EXTENSIONS = get_bool_env("ENABLE_TRANSACTIONS_EXTENSIONS", default=True)
64
+ ENABLE_COLLECTIONS_SEARCH = get_bool_env("ENABLE_COLLECTIONS_SEARCH", default=True)
65
+ ENABLE_COLLECTIONS_SEARCH_ROUTE = get_bool_env(
66
+ "ENABLE_COLLECTIONS_SEARCH_ROUTE", default=False
67
+ )
58
68
  logger.info("TRANSACTIONS_EXTENSIONS is set to %s", TRANSACTIONS_EXTENSIONS)
69
+ logger.info("ENABLE_COLLECTIONS_SEARCH is set to %s", ENABLE_COLLECTIONS_SEARCH)
70
+ logger.info(
71
+ "ENABLE_COLLECTIONS_SEARCH_ROUTE is set to %s", ENABLE_COLLECTIONS_SEARCH_ROUTE
72
+ )
59
73
 
60
74
  settings = ElasticsearchSettings()
61
75
  session = Session.create_from_settings(settings)
@@ -70,14 +84,6 @@ filter_extension.conformance_classes.append(
70
84
  FilterConformanceClasses.ADVANCED_COMPARISON_OPERATORS
71
85
  )
72
86
 
73
- # Adding collection search extension for compatibility with stac-auth-proxy
74
- # (https://github.com/developmentseed/stac-auth-proxy)
75
- # The extension is not fully implemented yet but is required for collection filtering support
76
- collection_search_extension = CollectionSearchExtension()
77
- collection_search_extension.conformance_classes.append(
78
- "https://api.stacspec.org/v1.0.0-rc.1/collection-search#filter"
79
- )
80
-
81
87
  aggregation_extension = AggregationExtension(
82
88
  client=EsAsyncBaseAggregationClient(
83
89
  database=database_logic, session=session, settings=settings
@@ -96,7 +102,6 @@ search_extensions = [
96
102
  TokenPaginationExtension(),
97
103
  filter_extension,
98
104
  FreeTextExtension(),
99
- collection_search_extension,
100
105
  ]
101
106
 
102
107
  if TRANSACTIONS_EXTENSIONS:
@@ -122,6 +127,80 @@ if TRANSACTIONS_EXTENSIONS:
122
127
 
123
128
  extensions = [aggregation_extension] + search_extensions
124
129
 
130
+ # Collection search related variables
131
+ collections_get_request_model = None
132
+
133
+ if ENABLE_COLLECTIONS_SEARCH or ENABLE_COLLECTIONS_SEARCH_ROUTE:
134
+ # Create collection search extensions
135
+ collection_search_extensions = [
136
+ QueryExtension(conformance_classes=[QueryConformanceClasses.COLLECTIONS]),
137
+ SortExtension(conformance_classes=[SortConformanceClasses.COLLECTIONS]),
138
+ FieldsExtension(conformance_classes=[FieldsConformanceClasses.COLLECTIONS]),
139
+ CollectionSearchFilterExtension(
140
+ conformance_classes=[FilterConformanceClasses.COLLECTIONS]
141
+ ),
142
+ FreeTextExtension(conformance_classes=[FreeTextConformanceClasses.COLLECTIONS]),
143
+ ]
144
+
145
+ # Initialize collection search with its extensions
146
+ collection_search_ext = CollectionSearchExtension.from_extensions(
147
+ collection_search_extensions
148
+ )
149
+ collections_get_request_model = collection_search_ext.GET
150
+
151
+ # Create a post request model for collection search
152
+ collection_search_post_request_model = create_post_request_model(
153
+ collection_search_extensions
154
+ )
155
+
156
+ # Create collection search extensions if enabled
157
+ if ENABLE_COLLECTIONS_SEARCH:
158
+ # Initialize collection search POST extension
159
+ collection_search_post_ext = CollectionSearchPostExtension(
160
+ client=CoreClient(
161
+ database=database_logic,
162
+ session=session,
163
+ post_request_model=collection_search_post_request_model,
164
+ landing_page_id=os.getenv("STAC_FASTAPI_LANDING_PAGE_ID", "stac-fastapi"),
165
+ ),
166
+ settings=settings,
167
+ POST=collection_search_post_request_model,
168
+ conformance_classes=[
169
+ "https://api.stacspec.org/v1.0.0-rc.1/collection-search",
170
+ QueryConformanceClasses.COLLECTIONS,
171
+ FilterConformanceClasses.COLLECTIONS,
172
+ FreeTextConformanceClasses.COLLECTIONS,
173
+ SortConformanceClasses.COLLECTIONS,
174
+ FieldsConformanceClasses.COLLECTIONS,
175
+ ],
176
+ )
177
+ extensions.append(collection_search_ext)
178
+ extensions.append(collection_search_post_ext)
179
+
180
+ if ENABLE_COLLECTIONS_SEARCH_ROUTE:
181
+ # Initialize collections-search endpoint extension
182
+ collections_search_endpoint_ext = CollectionsSearchEndpointExtension(
183
+ client=CoreClient(
184
+ database=database_logic,
185
+ session=session,
186
+ post_request_model=collection_search_post_request_model,
187
+ landing_page_id=os.getenv("STAC_FASTAPI_LANDING_PAGE_ID", "stac-fastapi"),
188
+ ),
189
+ settings=settings,
190
+ GET=collections_get_request_model,
191
+ POST=collection_search_post_request_model,
192
+ conformance_classes=[
193
+ "https://api.stacspec.org/v1.0.0-rc.1/collection-search",
194
+ QueryConformanceClasses.COLLECTIONS,
195
+ FilterConformanceClasses.COLLECTIONS,
196
+ FreeTextConformanceClasses.COLLECTIONS,
197
+ SortConformanceClasses.COLLECTIONS,
198
+ FieldsConformanceClasses.COLLECTIONS,
199
+ ],
200
+ )
201
+ extensions.append(collections_search_endpoint_ext)
202
+
203
+
125
204
  database_logic.extensions = [type(ext).__name__ for ext in extensions]
126
205
 
127
206
  post_request_model = create_post_request_model(search_extensions)
@@ -160,6 +239,10 @@ app_config = {
160
239
  "route_dependencies": get_route_dependencies(),
161
240
  }
162
241
 
242
+ # Add collections_get_request_model if it was created
243
+ if collections_get_request_model:
244
+ app_config["collections_get_request_model"] = collections_get_request_model
245
+
163
246
  api = StacApi(**app_config)
164
247
 
165
248
 
@@ -174,6 +257,7 @@ async def lifespan(app: FastAPI):
174
257
  app = api.app
175
258
  app.router.lifespan_context = lifespan
176
259
  app.root_path = os.getenv("STAC_FASTAPI_ROOT_PATH", "")
260
+
177
261
  # Add rate limit
178
262
  setup_rate_limit(app, rate_limit=os.getenv("STAC_FASTAPI_RATE_LIMIT"))
179
263
 
@@ -17,7 +17,7 @@ from starlette.requests import Request
17
17
 
18
18
  from stac_fastapi.core.base_database_logic import BaseDatabaseLogic
19
19
  from stac_fastapi.core.serializers import CollectionSerializer, ItemSerializer
20
- from stac_fastapi.core.utilities import bbox2polygon, get_max_limit
20
+ from stac_fastapi.core.utilities import bbox2polygon, get_bool_env, get_max_limit
21
21
  from stac_fastapi.elasticsearch.config import AsyncElasticsearchSettings
22
22
  from stac_fastapi.elasticsearch.config import (
23
23
  ElasticsearchSettings as SyncElasticsearchSettings,
@@ -170,30 +170,190 @@ class DatabaseLogic(BaseDatabaseLogic):
170
170
  """CORE LOGIC"""
171
171
 
172
172
  async def get_all_collections(
173
- self, token: Optional[str], limit: int, request: Request
174
- ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
175
- """Retrieve a list of all collections from Elasticsearch, supporting pagination.
173
+ self,
174
+ token: Optional[str],
175
+ limit: int,
176
+ request: Request,
177
+ sort: Optional[List[Dict[str, Any]]] = None,
178
+ q: Optional[List[str]] = None,
179
+ filter: Optional[Dict[str, Any]] = None,
180
+ query: Optional[Dict[str, Dict[str, Any]]] = None,
181
+ datetime: Optional[str] = None,
182
+ ) -> Tuple[List[Dict[str, Any]], Optional[str], Optional[int]]:
183
+ """Retrieve a list of collections from Elasticsearch, supporting pagination.
176
184
 
177
185
  Args:
178
186
  token (Optional[str]): The pagination token.
179
187
  limit (int): The number of results to return.
188
+ request (Request): The FastAPI request object.
189
+ sort (Optional[List[Dict[str, Any]]]): Optional sort parameter from the request.
190
+ q (Optional[List[str]]): Free text search terms.
191
+ query (Optional[Dict[str, Dict[str, Any]]]): Query extension parameters.
192
+ filter (Optional[Dict[str, Any]]): Structured query in CQL2 format.
193
+ datetime (Optional[str]): Temporal filter.
180
194
 
181
195
  Returns:
182
196
  A tuple of (collections, next pagination token if any).
197
+
198
+ Raises:
199
+ HTTPException: If sorting is requested on a field that is not sortable.
183
200
  """
201
+ # Define sortable fields based on the ES_COLLECTIONS_MAPPINGS
202
+ sortable_fields = ["id", "extent.temporal.interval", "temporal"]
203
+
204
+ # Format the sort parameter
205
+ formatted_sort = []
206
+ if sort:
207
+ for item in sort:
208
+ field = item.get("field")
209
+ direction = item.get("direction", "asc")
210
+ if field:
211
+ # Validate that the field is sortable
212
+ if field not in sortable_fields:
213
+ raise HTTPException(
214
+ status_code=400,
215
+ detail=f"Field '{field}' is not sortable. Sortable fields are: {', '.join(sortable_fields)}. "
216
+ + "Text fields are not sortable by default in Elasticsearch. "
217
+ + "To make a field sortable, update the mapping to use 'keyword' type or add a '.keyword' subfield. ",
218
+ )
219
+ formatted_sort.append({field: {"order": direction}})
220
+ # Always include id as a secondary sort to ensure consistent pagination
221
+ if not any("id" in item for item in formatted_sort):
222
+ formatted_sort.append({"id": {"order": "asc"}})
223
+ else:
224
+ formatted_sort = [{"id": {"order": "asc"}}]
225
+
226
+ body = {
227
+ "sort": formatted_sort,
228
+ "size": limit,
229
+ }
230
+
231
+ # Handle search_after token - split by '|' to get all sort values
184
232
  search_after = None
185
233
  if token:
186
- search_after = [token]
234
+ try:
235
+ # The token should be a pipe-separated string of sort values
236
+ # e.g., "2023-01-01T00:00:00Z|collection-1"
237
+ search_after = token.split("|")
238
+ # If the number of sort fields doesn't match token parts, ignore the token
239
+ if len(search_after) != len(formatted_sort):
240
+ search_after = None
241
+ except Exception:
242
+ search_after = None
243
+
244
+ if search_after is not None:
245
+ body["search_after"] = search_after
246
+
247
+ # Build the query part of the body
248
+ query_parts = []
249
+
250
+ # Apply free text query if provided
251
+ if q:
252
+ # For collections, we want to search across all relevant fields
253
+ should_clauses = []
254
+
255
+ # For each search term
256
+ for term in q:
257
+ # Create a multi_match query for each term
258
+ for field in [
259
+ "id",
260
+ "title",
261
+ "description",
262
+ "keywords",
263
+ "summaries.platform",
264
+ "summaries.constellation",
265
+ "providers.name",
266
+ "providers.url",
267
+ ]:
268
+ should_clauses.append(
269
+ {
270
+ "wildcard": {
271
+ field: {"value": f"*{term}*", "case_insensitive": True}
272
+ }
273
+ }
274
+ )
187
275
 
188
- response = await self.client.search(
189
- index=COLLECTIONS_INDEX,
190
- body={
191
- "sort": [{"id": {"order": "asc"}}],
192
- "size": limit,
193
- **({"search_after": search_after} if search_after is not None else {}),
194
- },
276
+ # Add the free text query to the query parts
277
+ query_parts.append(
278
+ {"bool": {"should": should_clauses, "minimum_should_match": 1}}
279
+ )
280
+
281
+ # Apply structured filter if provided
282
+ if filter:
283
+ # Convert string filter to dict if needed
284
+ if isinstance(filter, str):
285
+ filter = orjson.loads(filter)
286
+ # Convert the filter to an Elasticsearch query using the filter module
287
+ es_query = filter_module.to_es(await self.get_queryables_mapping(), filter)
288
+ query_parts.append(es_query)
289
+
290
+ # Apply query extension if provided
291
+ if query:
292
+ try:
293
+ # First create a search object to apply filters
294
+ search = Search(index=COLLECTIONS_INDEX)
295
+
296
+ # Process each field and operator in the query
297
+ for field_name, expr in query.items():
298
+ for op, value in expr.items():
299
+ # For collections, we don't need to prefix with 'properties__'
300
+ field = field_name
301
+ # Apply the filter using apply_stacql_filter
302
+ search = self.apply_stacql_filter(
303
+ search=search, op=op, field=field, value=value
304
+ )
305
+
306
+ # Convert the search object to a query dict and add it to query_parts
307
+ search_dict = search.to_dict()
308
+ if "query" in search_dict:
309
+ query_parts.append(search_dict["query"])
310
+
311
+ except Exception as e:
312
+ logger = logging.getLogger(__name__)
313
+ logger.error(f"Error converting query to Elasticsearch: {e}")
314
+ # If there's an error, add a query that matches nothing
315
+ query_parts.append({"bool": {"must_not": {"match_all": {}}}})
316
+ raise
317
+
318
+ # Combine all query parts with AND logic if there are multiple
319
+ datetime_filter = None
320
+ if datetime:
321
+ datetime_filter = self._apply_collection_datetime_filter(datetime)
322
+ if datetime_filter:
323
+ query_parts.append(datetime_filter)
324
+
325
+ # Combine all query parts with AND logic
326
+ if query_parts:
327
+ body["query"] = (
328
+ query_parts[0]
329
+ if len(query_parts) == 1
330
+ else {"bool": {"must": query_parts}}
331
+ )
332
+
333
+ # Create a copy of the body for count query (without pagination and sorting)
334
+ count_body = body.copy()
335
+ if "search_after" in count_body:
336
+ del count_body["search_after"]
337
+ count_body["size"] = 0
338
+
339
+ # Create async tasks for both search and count
340
+ search_task = asyncio.create_task(
341
+ self.client.search(
342
+ index=COLLECTIONS_INDEX,
343
+ body=body,
344
+ )
195
345
  )
196
346
 
347
+ count_task = asyncio.create_task(
348
+ self.client.count(
349
+ index=COLLECTIONS_INDEX,
350
+ body={"query": body.get("query", {"match_all": {}})},
351
+ )
352
+ )
353
+
354
+ # Wait for search task to complete
355
+ response = await search_task
356
+
197
357
  hits = response["hits"]["hits"]
198
358
  collections = [
199
359
  self.collection_serializer.db_to_stac(
@@ -204,9 +364,62 @@ class DatabaseLogic(BaseDatabaseLogic):
204
364
 
205
365
  next_token = None
206
366
  if len(hits) == limit:
207
- next_token = hits[-1]["sort"][0]
367
+ next_token_values = hits[-1].get("sort")
368
+ if next_token_values:
369
+ # Join all sort values with '|' to create the token
370
+ next_token = "|".join(str(val) for val in next_token_values)
208
371
 
209
- return collections, next_token
372
+ # Get the total count of collections
373
+ matched = (
374
+ response["hits"]["total"]["value"]
375
+ if response["hits"]["total"]["relation"] == "eq"
376
+ else None
377
+ )
378
+
379
+ # If count task is done, use its result
380
+ if count_task.done():
381
+ try:
382
+ matched = count_task.result().get("count")
383
+ except Exception as e:
384
+ logger = logging.getLogger(__name__)
385
+ logger.error(f"Count task failed: {e}")
386
+
387
+ return collections, next_token, matched
388
+
389
+ @staticmethod
390
+ def _apply_collection_datetime_filter(
391
+ datetime_str: Optional[str],
392
+ ) -> Optional[Dict[str, Any]]:
393
+ """Create a temporal filter for collections based on their extent."""
394
+ if not datetime_str:
395
+ return None
396
+
397
+ # Parse the datetime string into start and end
398
+ if "/" in datetime_str:
399
+ start, end = datetime_str.split("/")
400
+ # Replace open-ended ranges with concrete dates
401
+ if start == "..":
402
+ # For open-ended start, use a very early date
403
+ start = "1800-01-01T00:00:00Z"
404
+ if end == "..":
405
+ # For open-ended end, use a far future date
406
+ end = "2999-12-31T23:59:59Z"
407
+ else:
408
+ # If it's just a single date, use it for both start and end
409
+ start = end = datetime_str
410
+
411
+ return {
412
+ "bool": {
413
+ "must": [
414
+ # Check if any date in the array is less than or equal to the query end date
415
+ # This will match if the collection's start date is before or equal to the query end date
416
+ {"range": {"extent.temporal.interval": {"lte": end}}},
417
+ # Check if any date in the array is greater than or equal to the query start date
418
+ # This will match if the collection's end date is after or equal to the query start date
419
+ {"range": {"extent.temporal.interval": {"gte": start}}},
420
+ ]
421
+ }
422
+ }
210
423
 
211
424
  async def get_one_item(self, collection_id: str, item_id: str) -> Dict:
212
425
  """Retrieve a single item from the database.
@@ -289,26 +502,99 @@ class DatabaseLogic(BaseDatabaseLogic):
289
502
  Returns:
290
503
  The filtered search object.
291
504
  """
505
+ # USE_DATETIME env var
506
+ # True: Search by datetime, if null search by start/end datetime
507
+ # False: Always search only by start/end datetime
508
+ USE_DATETIME = get_bool_env("USE_DATETIME", default=True)
509
+
292
510
  datetime_search = return_date(datetime)
293
511
 
294
512
  if not datetime_search:
295
513
  return search, datetime_search
296
514
 
297
- if "eq" in datetime_search:
298
- # For exact matches, include:
299
- # 1. Items with matching exact datetime
300
- # 2. Items with datetime:null where the time falls within their range
301
- should = [
302
- Q(
303
- "bool",
304
- filter=[
305
- Q("exists", field="properties.datetime"),
306
- Q("term", **{"properties__datetime": datetime_search["eq"]}),
307
- ],
308
- ),
309
- Q(
515
+ if USE_DATETIME:
516
+ if "eq" in datetime_search:
517
+ # For exact matches, include:
518
+ # 1. Items with matching exact datetime
519
+ # 2. Items with datetime:null where the time falls within their range
520
+ should = [
521
+ Q(
522
+ "bool",
523
+ filter=[
524
+ Q("exists", field="properties.datetime"),
525
+ Q(
526
+ "term",
527
+ **{"properties__datetime": datetime_search["eq"]},
528
+ ),
529
+ ],
530
+ ),
531
+ Q(
532
+ "bool",
533
+ must_not=[Q("exists", field="properties.datetime")],
534
+ filter=[
535
+ Q("exists", field="properties.start_datetime"),
536
+ Q("exists", field="properties.end_datetime"),
537
+ Q(
538
+ "range",
539
+ properties__start_datetime={
540
+ "lte": datetime_search["eq"]
541
+ },
542
+ ),
543
+ Q(
544
+ "range",
545
+ properties__end_datetime={"gte": datetime_search["eq"]},
546
+ ),
547
+ ],
548
+ ),
549
+ ]
550
+ else:
551
+ # For date ranges, include:
552
+ # 1. Items with datetime in the range
553
+ # 2. Items with datetime:null that overlap the search range
554
+ should = [
555
+ Q(
556
+ "bool",
557
+ filter=[
558
+ Q("exists", field="properties.datetime"),
559
+ Q(
560
+ "range",
561
+ properties__datetime={
562
+ "gte": datetime_search["gte"],
563
+ "lte": datetime_search["lte"],
564
+ },
565
+ ),
566
+ ],
567
+ ),
568
+ Q(
569
+ "bool",
570
+ must_not=[Q("exists", field="properties.datetime")],
571
+ filter=[
572
+ Q("exists", field="properties.start_datetime"),
573
+ Q("exists", field="properties.end_datetime"),
574
+ Q(
575
+ "range",
576
+ properties__start_datetime={
577
+ "lte": datetime_search["lte"]
578
+ },
579
+ ),
580
+ Q(
581
+ "range",
582
+ properties__end_datetime={
583
+ "gte": datetime_search["gte"]
584
+ },
585
+ ),
586
+ ],
587
+ ),
588
+ ]
589
+
590
+ return (
591
+ search.query(Q("bool", should=should, minimum_should_match=1)),
592
+ datetime_search,
593
+ )
594
+ else:
595
+ if "eq" in datetime_search:
596
+ filter_query = Q(
310
597
  "bool",
311
- must_not=[Q("exists", field="properties.datetime")],
312
598
  filter=[
313
599
  Q("exists", field="properties.start_datetime"),
314
600
  Q("exists", field="properties.end_datetime"),
@@ -321,29 +607,10 @@ class DatabaseLogic(BaseDatabaseLogic):
321
607
  properties__end_datetime={"gte": datetime_search["eq"]},
322
608
  ),
323
609
  ],
324
- ),
325
- ]
326
- else:
327
- # For date ranges, include:
328
- # 1. Items with datetime in the range
329
- # 2. Items with datetime:null that overlap the search range
330
- should = [
331
- Q(
332
- "bool",
333
- filter=[
334
- Q("exists", field="properties.datetime"),
335
- Q(
336
- "range",
337
- properties__datetime={
338
- "gte": datetime_search["gte"],
339
- "lte": datetime_search["lte"],
340
- },
341
- ),
342
- ],
343
- ),
344
- Q(
610
+ )
611
+ else:
612
+ filter_query = Q(
345
613
  "bool",
346
- must_not=[Q("exists", field="properties.datetime")],
347
614
  filter=[
348
615
  Q("exists", field="properties.start_datetime"),
349
616
  Q("exists", field="properties.end_datetime"),
@@ -356,13 +623,8 @@ class DatabaseLogic(BaseDatabaseLogic):
356
623
  properties__end_datetime={"gte": datetime_search["gte"]},
357
624
  ),
358
625
  ],
359
- ),
360
- ]
361
-
362
- return (
363
- search.query(Q("bool", should=should, minimum_should_match=1)),
364
- datetime_search,
365
- )
626
+ )
627
+ return search.query(filter_query), datetime_search
366
628
 
367
629
  @staticmethod
368
630
  def apply_bbox_filter(search: Search, bbox: List):
@@ -421,18 +683,31 @@ class DatabaseLogic(BaseDatabaseLogic):
421
683
 
422
684
  Args:
423
685
  search (Search): The search object to apply the filter to.
424
- op (str): The comparison operator to use. Can be 'eq' (equal), 'gt' (greater than), 'gte' (greater than or equal),
425
- 'lt' (less than), or 'lte' (less than or equal).
686
+ op (str): The comparison operator to use. Can be 'eq' (equal), 'ne'/'neq' (not equal), 'gt' (greater than),
687
+ 'gte' (greater than or equal), 'lt' (less than), or 'lte' (less than or equal).
426
688
  field (str): The field to perform the comparison on.
427
689
  value (float): The value to compare the field against.
428
690
 
429
691
  Returns:
430
692
  search (Search): The search object with the specified filter applied.
431
693
  """
432
- if op != "eq":
694
+ if op == "eq":
695
+ search = search.filter("term", **{field: value})
696
+ elif op == "ne" or op == "neq":
697
+ # For not equal, use a bool query with must_not
698
+ search = search.exclude("term", **{field: value})
699
+ elif op in ["gt", "gte", "lt", "lte"]:
700
+ # For range operators
433
701
  key_filter = {field: {op: value}}
434
702
  search = search.filter(Q("range", **key_filter))
435
- else:
703
+ elif op == "in":
704
+ # For in operator (value should be a list)
705
+ if isinstance(value, list):
706
+ search = search.filter("terms", **{field: value})
707
+ else:
708
+ search = search.filter("term", **{field: value})
709
+ elif op == "contains":
710
+ # For contains operator (for arrays)
436
711
  search = search.filter("term", **{field: value})
437
712
 
438
713
  return search
@@ -1,2 +1,2 @@
1
1
  """library version."""
2
- __version__ = "6.3.0"
2
+ __version__ = "6.5.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: stac-fastapi-elasticsearch
3
- Version: 6.3.0
3
+ Version: 6.5.0
4
4
  Summary: An implementation of STAC API based on the FastAPI framework with both Elasticsearch and Opensearch.
5
5
  Home-page: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch
6
6
  License: MIT
@@ -15,8 +15,8 @@ Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: License :: OSI Approved :: MIT License
16
16
  Requires-Python: >=3.9
17
17
  Description-Content-Type: text/markdown
18
- Requires-Dist: stac-fastapi-core==6.3.0
19
- Requires-Dist: sfeos-helpers==6.3.0
18
+ Requires-Dist: stac-fastapi-core==6.5.0
19
+ Requires-Dist: sfeos-helpers==6.5.0
20
20
  Requires-Dist: elasticsearch[async]~=8.18.0
21
21
  Requires-Dist: uvicorn~=0.23.0
22
22
  Requires-Dist: starlette<0.36.0,>=0.35.0
@@ -72,11 +72,10 @@ SFEOS (stac-fastapi-elasticsearch-opensearch) is a high-performance, scalable AP
72
72
  - **Scale to millions of geospatial assets** with fast search performance through optimized spatial indexing and query capabilities
73
73
  - **Support OGC-compliant filtering** including spatial operations (intersects, contains, etc.) and temporal queries
74
74
  - **Perform geospatial aggregations** to analyze data distribution across space and time
75
+ - **Enhanced collection search capabilities** with support for sorting and field selection
75
76
 
76
77
  This implementation builds on the STAC-FastAPI framework, providing a production-ready solution specifically optimized for Elasticsearch and OpenSearch databases. It's ideal for organizations managing large geospatial data catalogs who need efficient discovery and access capabilities through standardized APIs.
77
78
 
78
-
79
-
80
79
  ## Common Deployment Patterns
81
80
 
82
81
  stac-fastapi-elasticsearch-opensearch can be deployed in several ways depending on your needs:
@@ -103,12 +102,13 @@ This project is built on the following technologies: STAC, stac-fastapi, FastAPI
103
102
  ## Table of Contents
104
103
 
105
104
  - [stac-fastapi-elasticsearch-opensearch](#stac-fastapi-elasticsearch-opensearch)
106
- - [Sponsors \& Supporters](#sponsors--supporters)
105
+ - [Sponsors & Supporters](#sponsors--supporters)
107
106
  - [Project Introduction - What is SFEOS?](#project-introduction---what-is-sfeos)
108
107
  - [Common Deployment Patterns](#common-deployment-patterns)
109
108
  - [Technologies](#technologies)
110
109
  - [Table of Contents](#table-of-contents)
111
- - [Documentation \& Resources](#documentation--resources)
110
+ - [Collection Search Extensions](#collection-search-extensions)
111
+ - [Documentation & Resources](#documentation--resources)
112
112
  - [Package Structure](#package-structure)
113
113
  - [Examples](#examples)
114
114
  - [Performance](#performance)
@@ -149,6 +149,59 @@ This project is built on the following technologies: STAC, stac-fastapi, FastAPI
149
149
  - [Gitter Chat](https://app.gitter.im/#/room/#stac-fastapi-elasticsearch_community:gitter.im) - For real-time discussions
150
150
  - [GitHub Discussions](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/discussions) - For longer-form questions and answers
151
151
 
152
+ ## Collection Search Extensions
153
+
154
+ SFEOS provides enhanced collection search capabilities through two primary routes:
155
+ - **GET/POST `/collections`**: The standard STAC endpoint with extended query parameters
156
+ - **GET/POST `/collections-search`**: A custom endpoint that supports the same parameters, created to avoid conflicts with the STAC Transactions extension if enabled (which uses POST `/collections` for collection creation)
157
+
158
+ These endpoints support advanced collection discovery features including:
159
+
160
+ - **Sorting**: Sort collections by sortable fields using the `sortby` parameter
161
+ - Example: `/collections?sortby=+id` (ascending sort by ID)
162
+ - Example: `/collections?sortby=-id` (descending sort by ID)
163
+ - Example: `/collections?sortby=-temporal` (descending sort by temporal extent)
164
+
165
+ - **Field Selection**: Request only specific fields to be returned using the `fields` parameter
166
+ - Example: `/collections?fields=id,title,description`
167
+ - This helps reduce payload size when only certain fields are needed
168
+
169
+ - **Free Text Search**: Search across collection text fields using the `q` parameter
170
+ - Example: `/collections?q=landsat`
171
+ - Searches across multiple text fields including title, description, and keywords
172
+ - Supports partial word matching and relevance-based sorting
173
+
174
+ - **Structured Filtering**: Filter collections using CQL2 expressions
175
+ - JSON format: `/collections?filter={"op":"=","args":[{"property":"id"},"sentinel-2"]}&filter-lang=cql2-json`
176
+ - Text format: `/collections?filter=id='sentinel-2'&filter-lang=cql2-text` (note: string values must be quoted)
177
+ - Advanced text format: `/collections?filter=id LIKE '%sentinel%'&filter-lang=cql2-text` (supports LIKE, BETWEEN, etc.)
178
+ - Supports both CQL2 JSON and CQL2 text formats with various operators
179
+ - Enables precise filtering on any collection property
180
+
181
+ - **Datetime Filtering**: Filter collections by their temporal extent using the `datetime` parameter
182
+ - Example: `/collections?datetime=2020-01-01T00:00:00Z/2020-12-31T23:59:59Z` (finds collections with temporal extents that overlap this range)
183
+ - Example: `/collections?datetime=2020-06-15T12:00:00Z` (finds collections whose temporal extent includes this specific time)
184
+ - Example: `/collections?datetime=2020-01-01T00:00:00Z/..` (finds collections with temporal extents that extend to or beyond January 1, 2020)
185
+ - Example: `/collections?datetime=../2020-12-31T23:59:59Z` (finds collections with temporal extents that begin on or before December 31, 2020)
186
+ - Collections are matched if their temporal extent overlaps with the provided datetime parameter
187
+ - This allows for efficient discovery of collections based on time periods
188
+
189
+ These extensions make it easier to build user interfaces that display and navigate through collections efficiently.
190
+
191
+ > **Configuration**: Collection search extensions (sorting, field selection, free text search, structured filtering, and datetime filtering) for the `/collections` endpoint can be disabled by setting the `ENABLE_COLLECTIONS_SEARCH` environment variable to `false`. By default, these extensions are enabled.
192
+ >
193
+ > **Configuration**: The custom `/collections-search` endpoint can be enabled by setting the `ENABLE_COLLECTIONS_SEARCH_ROUTE` environment variable to `true`. By default, this endpoint is **disabled**.
194
+
195
+ > **Note**: Sorting is only available on fields that are indexed for sorting in Elasticsearch/OpenSearch. With the default mappings, you can sort on:
196
+ > - `id` (keyword field)
197
+ > - `extent.temporal.interval` (date field)
198
+ > - `temporal` (alias to extent.temporal.interval)
199
+ >
200
+ > Text fields like `title` and `description` are not sortable by default as they use text analysis for better search capabilities. Attempting to sort on these fields will result in a user-friendly error message explaining which fields are sortable and how to make additional fields sortable by updating the mappings.
201
+ >
202
+ > **Important**: Adding keyword fields to make text fields sortable can significantly increase the index size, especially for large text fields. Consider the storage implications when deciding which fields to make sortable.
203
+
204
+
152
205
  ## Package Structure
153
206
 
154
207
  This project is organized into several packages, each with a specific purpose:
@@ -161,7 +214,7 @@ This project is organized into several packages, each with a specific purpose:
161
214
  - Shared logic and utilities that improve code reuse between backends
162
215
 
163
216
  - **stac_fastapi_elasticsearch**: Complete implementation of the STAC API using Elasticsearch as the backend database. This package depends on both `stac_fastapi_core` and `sfeos_helpers`.
164
- -
217
+
165
218
  - **stac_fastapi_opensearch**: Complete implementation of the STAC API using OpenSearch as the backend database. This package depends on both `stac_fastapi_core` and `sfeos_helpers`.
166
219
 
167
220
  ## Examples
@@ -279,10 +332,13 @@ You can customize additional settings in your `.env` file:
279
332
  | `ENABLE_DIRECT_RESPONSE` | Enable direct response for maximum performance (disables all FastAPI dependencies, including authentication, custom status codes, and validation) | `false` | Optional |
280
333
  | `RAISE_ON_BULK_ERROR` | Controls whether bulk insert operations raise exceptions on errors. If set to `true`, the operation will stop and raise an exception when an error occurs. If set to `false`, errors will be logged, and the operation will continue. **Note:** STAC Item and ItemCollection validation errors will always raise, regardless of this flag. | `false` | Optional |
281
334
  | `DATABASE_REFRESH` | Controls whether database operations refresh the index immediately after changes. If set to `true`, changes will be immediately searchable. If set to `false`, changes may not be immediately visible but can improve performance for bulk operations. If set to `wait_for`, changes will wait for the next refresh cycle to become visible. | `false` | Optional |
282
- | `ENABLE_TRANSACTIONS_EXTENSIONS` | Enables or disables the Transactions and Bulk Transactions API extensions. If set to `false`, the POST `/collections` route and related transaction endpoints (including bulk transaction operations) will be unavailable in the API. This is useful for deployments where mutating the catalog via the API should be prevented. | `true` | Optional |
335
+ | `ENABLE_COLLECTIONS_SEARCH` | Enable collection search extensions (sort, fields, free text search, structured filtering, and datetime filtering) on the core `/collections` endpoint. | `true` | Optional |
336
+ | `ENABLE_COLLECTIONS_SEARCH_ROUTE` | Enable the custom `/collections-search` endpoint (both GET and POST methods). When disabled, the custom endpoint will not be available, but collection search extensions will still be available on the core `/collections` endpoint if `ENABLE_COLLECTIONS_SEARCH` is true. | `false` | Optional |
337
+ | `ENABLE_TRANSACTIONS_EXTENSIONS` | Enables or disables the Transactions and Bulk Transactions API extensions. This is useful for deployments where mutating the catalog via the API should be prevented. If set to `true`, the POST `/collections` route for search will be unavailable in the API. | `true` | Optional |
283
338
  | `STAC_ITEM_LIMIT` | Sets the environment variable for result limiting to SFEOS for the number of returned items and STAC collections. | `10` | Optional |
284
339
  | `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 |
285
340
  | `ENV_MAX_LIMIT` | Configures the environment variable in SFEOS to override the default `MAX_LIMIT`, which controls the limit parameter for returned items and STAC collections. | `10,000` | Optional |
341
+ | `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 |
286
342
 
287
343
  > [!NOTE]
288
344
  > The variables `ES_HOST`, `ES_PORT`, `ES_USE_SSL`, `ES_VERIFY_CERTS` and `ES_TIMEOUT` apply to both Elasticsearch and OpenSearch backends, so there is no need to rename the key names to `OS_` even if you're using OpenSearch.
@@ -424,6 +480,9 @@ The system uses a precise naming convention:
424
480
  - **Root Path Configuration**: The application root path is the base URL by default.
425
481
  - For AWS Lambda with Gateway API: Set `STAC_FASTAPI_ROOT_PATH` to match the Gateway API stage name (e.g., `/v1`)
426
482
 
483
+ - **Feature Configuration**: Control which features are enabled:
484
+ - `ENABLE_COLLECTIONS_SEARCH`: Set to `true` (default) to enable collection search extensions (sort, fields). Set to `false` to disable.
485
+ - `ENABLE_TRANSACTIONS_EXTENSIONS`: Set to `true` (default) to enable transaction extensions. Set to `false` to disable.
427
486
 
428
487
  ## Collection Pagination
429
488
 
@@ -0,0 +1,10 @@
1
+ stac_fastapi/elasticsearch/__init__.py,sha256=w_MZutYLreNV372sCuO46bPb0TngmPs4u8737ueS0wE,31
2
+ stac_fastapi/elasticsearch/app.py,sha256=024U5xvXmSWUJABS9SekbqqamhsNSpqPBII2NBWktz8,9979
3
+ stac_fastapi/elasticsearch/config.py,sha256=itvPYr4TiOg9pWQrycgGaQxQ_Vc2KKP3aHdtH0OUZvw,5322
4
+ stac_fastapi/elasticsearch/database_logic.py,sha256=5Ykh2jiliOAHMxjtWoMDwKO7UPKhqWpqhenO3-Yv9z8,70289
5
+ stac_fastapi/elasticsearch/version.py,sha256=KQjuGSR03-CXgF6wsaZ8qsni161S2BjhOn3wTX8JAMw,45
6
+ stac_fastapi_elasticsearch-6.5.0.dist-info/METADATA,sha256=1E1X98TZlUW3r4kM9DWX1GDHBa18IRLJX-dNzR-mp2I,42084
7
+ stac_fastapi_elasticsearch-6.5.0.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
8
+ stac_fastapi_elasticsearch-6.5.0.dist-info/entry_points.txt,sha256=aCKixki0LpUl64UPsPMtiNvfdyq-QsTCxVjJ54VF6Jk,82
9
+ stac_fastapi_elasticsearch-6.5.0.dist-info/top_level.txt,sha256=vqn-D9-HsRPTTxy0Vk_KkDmTiMES4owwBQ3ydSZYb2s,13
10
+ stac_fastapi_elasticsearch-6.5.0.dist-info/RECORD,,
@@ -1,10 +0,0 @@
1
- stac_fastapi/elasticsearch/__init__.py,sha256=w_MZutYLreNV372sCuO46bPb0TngmPs4u8737ueS0wE,31
2
- stac_fastapi/elasticsearch/app.py,sha256=42586pNMswKIyC-Q9Lz3hJh88Gm60ibbOsBOpbD0x50,6579
3
- stac_fastapi/elasticsearch/config.py,sha256=itvPYr4TiOg9pWQrycgGaQxQ_Vc2KKP3aHdtH0OUZvw,5322
4
- stac_fastapi/elasticsearch/database_logic.py,sha256=M6xIgtl3HB9p2V6wZPB6xm6cY02iYa2oG2OJRUbpA0c,58488
5
- stac_fastapi/elasticsearch/version.py,sha256=rBLPQyvMDNA0PA0jXfByTouJPJn5p0wXiqmUWJMIfYc,45
6
- stac_fastapi_elasticsearch-6.3.0.dist-info/METADATA,sha256=ijEkw05ul0-pg5rRfPMG0oKnXQd2iRNzvaIW4J4cSBo,36626
7
- stac_fastapi_elasticsearch-6.3.0.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
8
- stac_fastapi_elasticsearch-6.3.0.dist-info/entry_points.txt,sha256=aCKixki0LpUl64UPsPMtiNvfdyq-QsTCxVjJ54VF6Jk,82
9
- stac_fastapi_elasticsearch-6.3.0.dist-info/top_level.txt,sha256=vqn-D9-HsRPTTxy0Vk_KkDmTiMES4owwBQ3ydSZYb2s,13
10
- stac_fastapi_elasticsearch-6.3.0.dist-info/RECORD,,