stac-fastapi-opensearch 4.0.0a1__tar.gz → 4.1.0__tar.gz

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.
Files changed (17) hide show
  1. {stac_fastapi_opensearch-4.0.0a1 → stac_fastapi_opensearch-4.1.0}/PKG-INFO +20 -7
  2. {stac_fastapi_opensearch-4.0.0a1 → stac_fastapi_opensearch-4.1.0}/README.md +19 -6
  3. {stac_fastapi_opensearch-4.0.0a1 → stac_fastapi_opensearch-4.1.0}/setup.py +1 -1
  4. {stac_fastapi_opensearch-4.0.0a1 → stac_fastapi_opensearch-4.1.0}/stac_fastapi/opensearch/app.py +15 -8
  5. {stac_fastapi_opensearch-4.0.0a1 → stac_fastapi_opensearch-4.1.0}/stac_fastapi/opensearch/config.py +37 -8
  6. {stac_fastapi_opensearch-4.0.0a1 → stac_fastapi_opensearch-4.1.0}/stac_fastapi/opensearch/database_logic.py +267 -88
  7. stac_fastapi_opensearch-4.1.0/stac_fastapi/opensearch/version.py +2 -0
  8. {stac_fastapi_opensearch-4.0.0a1 → stac_fastapi_opensearch-4.1.0}/stac_fastapi_opensearch.egg-info/PKG-INFO +20 -7
  9. {stac_fastapi_opensearch-4.0.0a1 → stac_fastapi_opensearch-4.1.0}/stac_fastapi_opensearch.egg-info/requires.txt +1 -1
  10. stac_fastapi_opensearch-4.0.0a1/stac_fastapi/opensearch/version.py +0 -2
  11. {stac_fastapi_opensearch-4.0.0a1 → stac_fastapi_opensearch-4.1.0}/setup.cfg +0 -0
  12. {stac_fastapi_opensearch-4.0.0a1 → stac_fastapi_opensearch-4.1.0}/stac_fastapi/opensearch/__init__.py +0 -0
  13. {stac_fastapi_opensearch-4.0.0a1 → stac_fastapi_opensearch-4.1.0}/stac_fastapi_opensearch.egg-info/SOURCES.txt +0 -0
  14. {stac_fastapi_opensearch-4.0.0a1 → stac_fastapi_opensearch-4.1.0}/stac_fastapi_opensearch.egg-info/dependency_links.txt +0 -0
  15. {stac_fastapi_opensearch-4.0.0a1 → stac_fastapi_opensearch-4.1.0}/stac_fastapi_opensearch.egg-info/entry_points.txt +0 -0
  16. {stac_fastapi_opensearch-4.0.0a1 → stac_fastapi_opensearch-4.1.0}/stac_fastapi_opensearch.egg-info/not-zip-safe +0 -0
  17. {stac_fastapi_opensearch-4.0.0a1 → stac_fastapi_opensearch-4.1.0}/stac_fastapi_opensearch.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: stac_fastapi_opensearch
3
- Version: 4.0.0a1
3
+ Version: 4.1.0
4
4
  Summary: Opensearch stac-fastapi backend.
5
5
  Home-page: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch
6
6
  License: MIT
@@ -50,8 +50,18 @@ Provides-Extra: server
50
50
  - There is [Postman](https://documenter.getpostman.com/view/12888943/2s8ZDSdRHA) documentation here for examples on how to run some of the API routes locally - after starting the elasticsearch backend via the compose.yml file.
51
51
  - The `/examples` folder shows an example of running stac-fastapi-elasticsearch from PyPI in docker without needing any code from the repository. There is also a Postman collection here that you can load into Postman for testing the API routes.
52
52
 
53
- - For changes, see the [Changelog](CHANGELOG.md)
54
- - We are always welcoming contributions. For the development notes: [Contributing](CONTRIBUTING.md)
53
+
54
+ ### Performance Note
55
+
56
+ The `enable_direct_response` option is provided by the stac-fastapi core library (introduced in stac-fastapi 5.2.0) and is available in this project starting from v4.0.0.
57
+
58
+ **You can now control this setting via the `ENABLE_DIRECT_RESPONSE` environment variable.**
59
+
60
+ When enabled (`ENABLE_DIRECT_RESPONSE=true`), endpoints return Starlette Response objects directly, bypassing FastAPI's default serialization for improved performance. **However, all FastAPI dependencies (including authentication, custom status codes, and validation) are disabled for all routes.**
61
+
62
+ This mode is best suited for public or read-only APIs where authentication and custom logic are not required. Default is `false` for safety.
63
+
64
+ See: [issue #347](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/issues/347)
55
65
 
56
66
 
57
67
  ### To install from PyPI:
@@ -95,8 +105,9 @@ If you wish to use a different version, put the following in a
95
105
  file named `.env` in the same directory you run Docker Compose from:
96
106
 
97
107
  ```shell
98
- ELASTICSEARCH_VERSION=7.17.1
99
- OPENSEARCH_VERSION=2.11.0
108
+ ELASTICSEARCH_VERSION=8.11.0
109
+ OPENSEARCH_VERSION=2.11.1
110
+ ENABLE_DIRECT_RESPONSE=false
100
111
  ```
101
112
  The most recent Elasticsearch 7.x versions should also work. See the [opensearch-py docs](https://github.com/opensearch-project/opensearch-py/blob/main/COMPATIBILITY.md) for compatibility information.
102
113
 
@@ -121,8 +132,10 @@ You can customize additional settings in your `.env` file:
121
132
  | `RELOAD` | Enable auto-reload for development. | `true` | Optional |
122
133
  | `STAC_FASTAPI_RATE_LIMIT` | API rate limit per client. | `200/minute` | Optional |
123
134
  | `BACKEND` | Tests-related variable | `elasticsearch` or `opensearch` based on the backend | Optional |
124
- | `ELASTICSEARCH_VERSION` | ElasticSearch version | `7.17.1` | Optional |
125
- | `OPENSEARCH_VERSION` | OpenSearch version | `2.11.0` | Optional |
135
+ | `ELASTICSEARCH_VERSION` | Version of Elasticsearch to use. | `8.11.0` | Optional |
136
+ | `ENABLE_DIRECT_RESPONSE` | Enable direct response for maximum performance (disables all FastAPI dependencies, including authentication, custom status codes, and validation) | `false` | Optional |
137
+ | `OPENSEARCH_VERSION` | OpenSearch version | `2.11.1` | Optional
138
+ | `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 |
126
139
 
127
140
  > [!NOTE]
128
141
  > The variables `ES_HOST`, `ES_PORT`, `ES_USE_SSL`, and `ES_VERIFY_CERTS` 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.
@@ -29,8 +29,18 @@
29
29
  - There is [Postman](https://documenter.getpostman.com/view/12888943/2s8ZDSdRHA) documentation here for examples on how to run some of the API routes locally - after starting the elasticsearch backend via the compose.yml file.
30
30
  - The `/examples` folder shows an example of running stac-fastapi-elasticsearch from PyPI in docker without needing any code from the repository. There is also a Postman collection here that you can load into Postman for testing the API routes.
31
31
 
32
- - For changes, see the [Changelog](CHANGELOG.md)
33
- - We are always welcoming contributions. For the development notes: [Contributing](CONTRIBUTING.md)
32
+
33
+ ### Performance Note
34
+
35
+ The `enable_direct_response` option is provided by the stac-fastapi core library (introduced in stac-fastapi 5.2.0) and is available in this project starting from v4.0.0.
36
+
37
+ **You can now control this setting via the `ENABLE_DIRECT_RESPONSE` environment variable.**
38
+
39
+ When enabled (`ENABLE_DIRECT_RESPONSE=true`), endpoints return Starlette Response objects directly, bypassing FastAPI's default serialization for improved performance. **However, all FastAPI dependencies (including authentication, custom status codes, and validation) are disabled for all routes.**
40
+
41
+ This mode is best suited for public or read-only APIs where authentication and custom logic are not required. Default is `false` for safety.
42
+
43
+ See: [issue #347](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/issues/347)
34
44
 
35
45
 
36
46
  ### To install from PyPI:
@@ -74,8 +84,9 @@ If you wish to use a different version, put the following in a
74
84
  file named `.env` in the same directory you run Docker Compose from:
75
85
 
76
86
  ```shell
77
- ELASTICSEARCH_VERSION=7.17.1
78
- OPENSEARCH_VERSION=2.11.0
87
+ ELASTICSEARCH_VERSION=8.11.0
88
+ OPENSEARCH_VERSION=2.11.1
89
+ ENABLE_DIRECT_RESPONSE=false
79
90
  ```
80
91
  The most recent Elasticsearch 7.x versions should also work. See the [opensearch-py docs](https://github.com/opensearch-project/opensearch-py/blob/main/COMPATIBILITY.md) for compatibility information.
81
92
 
@@ -100,8 +111,10 @@ You can customize additional settings in your `.env` file:
100
111
  | `RELOAD` | Enable auto-reload for development. | `true` | Optional |
101
112
  | `STAC_FASTAPI_RATE_LIMIT` | API rate limit per client. | `200/minute` | Optional |
102
113
  | `BACKEND` | Tests-related variable | `elasticsearch` or `opensearch` based on the backend | Optional |
103
- | `ELASTICSEARCH_VERSION` | ElasticSearch version | `7.17.1` | Optional |
104
- | `OPENSEARCH_VERSION` | OpenSearch version | `2.11.0` | Optional |
114
+ | `ELASTICSEARCH_VERSION` | Version of Elasticsearch to use. | `8.11.0` | Optional |
115
+ | `ENABLE_DIRECT_RESPONSE` | Enable direct response for maximum performance (disables all FastAPI dependencies, including authentication, custom status codes, and validation) | `false` | Optional |
116
+ | `OPENSEARCH_VERSION` | OpenSearch version | `2.11.1` | Optional
117
+ | `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 |
105
118
 
106
119
  > [!NOTE]
107
120
  > The variables `ES_HOST`, `ES_PORT`, `ES_USE_SSL`, and `ES_VERIFY_CERTS` 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.
@@ -6,7 +6,7 @@ with open("README.md") as f:
6
6
  desc = f.read()
7
7
 
8
8
  install_requires = [
9
- "stac-fastapi-core==4.0.0a1",
9
+ "stac-fastapi-core==4.1.0",
10
10
  "opensearch-py~=2.8.0",
11
11
  "opensearch-py[async]~=2.8.0",
12
12
  "uvicorn~=0.23.0",
@@ -1,6 +1,9 @@
1
1
  """FastAPI application."""
2
2
 
3
3
  import os
4
+ from contextlib import asynccontextmanager
5
+
6
+ from fastapi import FastAPI
4
7
 
5
8
  from stac_fastapi.api.app import StacApi
6
9
  from stac_fastapi.api.models import create_get_request_model, create_post_request_model
@@ -87,7 +90,7 @@ post_request_model = create_post_request_model(search_extensions)
87
90
  api = StacApi(
88
91
  title=os.getenv("STAC_FASTAPI_TITLE", "stac-fastapi-opensearch"),
89
92
  description=os.getenv("STAC_FASTAPI_DESCRIPTION", "stac-fastapi-opensearch"),
90
- api_version=os.getenv("STAC_FASTAPI_VERSION", "2.1"),
93
+ api_version=os.getenv("STAC_FASTAPI_VERSION", "4.1.0"),
91
94
  settings=settings,
92
95
  extensions=extensions,
93
96
  client=CoreClient(
@@ -97,17 +100,21 @@ api = StacApi(
97
100
  search_post_request_model=post_request_model,
98
101
  route_dependencies=get_route_dependencies(),
99
102
  )
100
- app = api.app
101
- app.root_path = os.getenv("STAC_FASTAPI_ROOT_PATH", "")
102
-
103
- # Add rate limit
104
- setup_rate_limit(app, rate_limit=os.getenv("STAC_FASTAPI_RATE_LIMIT"))
105
103
 
106
104
 
107
- @app.on_event("startup")
108
- async def _startup_event() -> None:
105
+ @asynccontextmanager
106
+ async def lifespan(app: FastAPI):
107
+ """Lifespan handler for FastAPI app. Initializes index templates and collections at startup."""
109
108
  await create_index_templates()
110
109
  await create_collection_index()
110
+ yield
111
+
112
+
113
+ app = api.app
114
+ app.router.lifespan_context = lifespan
115
+ app.root_path = os.getenv("STAC_FASTAPI_ROOT_PATH", "")
116
+ # Add rate limit
117
+ setup_rate_limit(app, rate_limit=os.getenv("STAC_FASTAPI_RATE_LIMIT"))
111
118
 
112
119
 
113
120
  def run() -> None:
@@ -1,4 +1,5 @@
1
1
  """API configuration."""
2
+ import logging
2
3
  import os
3
4
  import ssl
4
5
  from typing import Any, Dict, Set
@@ -7,12 +8,13 @@ import certifi
7
8
  from opensearchpy import AsyncOpenSearch, OpenSearch
8
9
 
9
10
  from stac_fastapi.core.base_settings import ApiBaseSettings
11
+ from stac_fastapi.core.utilities import get_bool_env
10
12
  from stac_fastapi.types.config import ApiSettings
11
13
 
12
14
 
13
15
  def _es_config() -> Dict[str, Any]:
14
16
  # Determine the scheme (http or https)
15
- use_ssl = os.getenv("ES_USE_SSL", "true").lower() == "true"
17
+ use_ssl = get_bool_env("ES_USE_SSL", default=True)
16
18
  scheme = "https" if use_ssl else "http"
17
19
 
18
20
  # Configure the hosts parameter with the correct scheme
@@ -33,7 +35,7 @@ def _es_config() -> Dict[str, Any]:
33
35
  "headers": {"accept": "application/json", "Content-Type": "application/json"},
34
36
  }
35
37
 
36
- http_compress = os.getenv("ES_HTTP_COMPRESS", "true").lower() == "true"
38
+ http_compress = get_bool_env("ES_HTTP_COMPRESS", default=True)
37
39
  if http_compress:
38
40
  config["http_compress"] = True
39
41
 
@@ -42,8 +44,8 @@ def _es_config() -> Dict[str, Any]:
42
44
  return config
43
45
 
44
46
  # Include SSL settings if using https
45
- config["ssl_version"] = ssl.PROTOCOL_SSLv23 # type: ignore
46
- config["verify_certs"] = os.getenv("ES_VERIFY_CERTS", "true").lower() != "false" # type: ignore
47
+ config["ssl_version"] = ssl.PROTOCOL_SSLv23
48
+ config["verify_certs"] = get_bool_env("ES_VERIFY_CERTS", default=True)
47
49
 
48
50
  # Include CA Certificates if verifying certs
49
51
  if config["verify_certs"]:
@@ -69,11 +71,19 @@ _forbidden_fields: Set[str] = {"type"}
69
71
 
70
72
 
71
73
  class OpensearchSettings(ApiSettings, ApiBaseSettings):
72
- """API settings."""
74
+ """
75
+ API settings.
76
+
77
+ Set enable_direct_response via the ENABLE_DIRECT_RESPONSE environment variable.
78
+ If enabled, all API routes use direct response for maximum performance, but ALL FastAPI dependencies (including authentication, custom status codes, and validation) are disabled.
79
+ Default is False for safety.
80
+ """
73
81
 
74
- # Fields which are defined by STAC but not included in the database model
75
82
  forbidden_fields: Set[str] = _forbidden_fields
76
83
  indexed_fields: Set[str] = {"datetime"}
84
+ enable_response_models: bool = False
85
+ enable_direct_response: bool = get_bool_env("ENABLE_DIRECT_RESPONSE", default=False)
86
+ raise_on_bulk_error: bool = get_bool_env("RAISE_ON_BULK_ERROR", default=False)
77
87
 
78
88
  @property
79
89
  def create_client(self):
@@ -82,13 +92,32 @@ class OpensearchSettings(ApiSettings, ApiBaseSettings):
82
92
 
83
93
 
84
94
  class AsyncOpensearchSettings(ApiSettings, ApiBaseSettings):
85
- """API settings."""
95
+ """
96
+ API settings.
97
+
98
+ Set enable_direct_response via the ENABLE_DIRECT_RESPONSE environment variable.
99
+ If enabled, all API routes use direct response for maximum performance, but ALL FastAPI dependencies (including authentication, custom status codes, and validation) are disabled.
100
+ Default is False for safety.
101
+ """
86
102
 
87
- # Fields which are defined by STAC but not included in the database model
88
103
  forbidden_fields: Set[str] = _forbidden_fields
89
104
  indexed_fields: Set[str] = {"datetime"}
105
+ enable_response_models: bool = False
106
+ enable_direct_response: bool = get_bool_env("ENABLE_DIRECT_RESPONSE", default=False)
107
+ raise_on_bulk_error: bool = get_bool_env("RAISE_ON_BULK_ERROR", default=False)
90
108
 
91
109
  @property
92
110
  def create_client(self):
93
111
  """Create async elasticsearch client."""
94
112
  return AsyncOpenSearch(**_es_config())
113
+
114
+
115
+ # Warn at import if direct response is enabled (applies to either settings class)
116
+ if (
117
+ OpensearchSettings().enable_direct_response
118
+ or AsyncOpensearchSettings().enable_direct_response
119
+ ):
120
+ logging.basicConfig(level=logging.WARNING)
121
+ logging.warning(
122
+ "ENABLE_DIRECT_RESPONSE is True: All FastAPI dependencies (including authentication) are DISABLED for all routes!"
123
+ )
@@ -9,12 +9,10 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple, Type
9
9
 
10
10
  import attr
11
11
  from opensearchpy import exceptions, helpers
12
- from opensearchpy.exceptions import TransportError
13
12
  from opensearchpy.helpers.query import Q
14
13
  from opensearchpy.helpers.search import Search
15
14
  from starlette.requests import Request
16
15
 
17
- from stac_fastapi.core import serializers
18
16
  from stac_fastapi.core.base_database_logic import BaseDatabaseLogic
19
17
  from stac_fastapi.core.database_logic import (
20
18
  COLLECTIONS_INDEX,
@@ -32,6 +30,7 @@ from stac_fastapi.core.database_logic import (
32
30
  mk_item_id,
33
31
  )
34
32
  from stac_fastapi.core.extensions import filter
33
+ from stac_fastapi.core.serializers import CollectionSerializer, ItemSerializer
35
34
  from stac_fastapi.core.utilities import MAX_LIMIT, bbox2polygon
36
35
  from stac_fastapi.opensearch.config import (
37
36
  AsyncOpensearchSettings as AsyncSearchSettings,
@@ -80,24 +79,21 @@ async def create_collection_index() -> None:
80
79
  """
81
80
  client = AsyncSearchSettings().create_client
82
81
 
83
- search_body: Dict[str, Any] = {
84
- "aliases": {COLLECTIONS_INDEX: {}},
85
- }
86
-
87
82
  index = f"{COLLECTIONS_INDEX}-000001"
88
83
 
89
- try:
90
- await client.indices.create(index=index, body=search_body)
91
- except TransportError as e:
92
- if e.status_code == 400:
93
- pass # Ignore 400 status codes
94
- else:
95
- raise e
96
-
84
+ exists = await client.indices.exists(index=index)
85
+ if not exists:
86
+ await client.indices.create(
87
+ index=index,
88
+ body={
89
+ "aliases": {COLLECTIONS_INDEX: {}},
90
+ "mappings": ES_COLLECTIONS_MAPPINGS,
91
+ },
92
+ )
97
93
  await client.close()
98
94
 
99
95
 
100
- async def create_item_index(collection_id: str):
96
+ async def create_item_index(collection_id: str) -> None:
101
97
  """
102
98
  Create the index for Items. The settings of the index template will be used implicitly.
103
99
 
@@ -109,24 +105,22 @@ async def create_item_index(collection_id: str):
109
105
 
110
106
  """
111
107
  client = AsyncSearchSettings().create_client
112
- search_body: Dict[str, Any] = {
113
- "aliases": {index_alias_by_collection_id(collection_id): {}},
114
- }
115
108
 
116
- try:
109
+ index_name = f"{index_by_collection_id(collection_id)}-000001"
110
+ exists = await client.indices.exists(index=index_name)
111
+ if not exists:
117
112
  await client.indices.create(
118
- index=f"{index_by_collection_id(collection_id)}-000001", body=search_body
113
+ index=index_name,
114
+ body={
115
+ "aliases": {index_alias_by_collection_id(collection_id): {}},
116
+ "mappings": ES_ITEMS_MAPPINGS,
117
+ "settings": ES_ITEMS_SETTINGS,
118
+ },
119
119
  )
120
- except TransportError as e:
121
- if e.status_code == 400:
122
- pass # Ignore 400 status codes
123
- else:
124
- raise e
125
-
126
120
  await client.close()
127
121
 
128
122
 
129
- async def delete_item_index(collection_id: str):
123
+ async def delete_item_index(collection_id: str) -> None:
130
124
  """Delete the index for items in a collection.
131
125
 
132
126
  Args:
@@ -149,14 +143,20 @@ async def delete_item_index(collection_id: str):
149
143
  class DatabaseLogic(BaseDatabaseLogic):
150
144
  """Database logic."""
151
145
 
152
- client = AsyncSearchSettings().create_client
153
- sync_client = SyncSearchSettings().create_client
146
+ async_settings: AsyncSearchSettings = attr.ib(factory=AsyncSearchSettings)
147
+ sync_settings: SyncSearchSettings = attr.ib(factory=SyncSearchSettings)
154
148
 
155
- item_serializer: Type[serializers.ItemSerializer] = attr.ib(
156
- default=serializers.ItemSerializer
157
- )
158
- collection_serializer: Type[serializers.CollectionSerializer] = attr.ib(
159
- default=serializers.CollectionSerializer
149
+ client = attr.ib(init=False)
150
+ sync_client = attr.ib(init=False)
151
+
152
+ def __attrs_post_init__(self):
153
+ """Initialize clients after the class is instantiated."""
154
+ self.client = self.async_settings.create_client
155
+ self.sync_client = self.sync_settings.create_client
156
+
157
+ item_serializer: Type[ItemSerializer] = attr.ib(default=ItemSerializer)
158
+ collection_serializer: Type[CollectionSerializer] = attr.ib(
159
+ default=CollectionSerializer
160
160
  )
161
161
 
162
162
  extensions: List[str] = attr.ib(default=attr.Factory(list))
@@ -335,7 +335,7 @@ class DatabaseLogic(BaseDatabaseLogic):
335
335
 
336
336
  @staticmethod
337
337
  def apply_datetime_filter(search: Search, datetime_search):
338
- """Apply a filter to search based on datetime field.
338
+ """Apply a filter to search based on datetime field, start_datetime, and end_datetime fields.
339
339
 
340
340
  Args:
341
341
  search (Search): The search object to filter.
@@ -344,17 +344,109 @@ class DatabaseLogic(BaseDatabaseLogic):
344
344
  Returns:
345
345
  Search: The filtered search object.
346
346
  """
347
+ should = []
348
+
349
+ # If the request is a single datetime return
350
+ # items with datetimes equal to the requested datetime OR
351
+ # the requested datetime is between their start and end datetimes
347
352
  if "eq" in datetime_search:
348
- search = search.filter(
349
- "term", **{"properties__datetime": datetime_search["eq"]}
353
+ should.extend(
354
+ [
355
+ Q(
356
+ "bool",
357
+ filter=[
358
+ Q(
359
+ "term",
360
+ properties__datetime=datetime_search["eq"],
361
+ ),
362
+ ],
363
+ ),
364
+ Q(
365
+ "bool",
366
+ filter=[
367
+ Q(
368
+ "range",
369
+ properties__start_datetime={
370
+ "lte": datetime_search["eq"],
371
+ },
372
+ ),
373
+ Q(
374
+ "range",
375
+ properties__end_datetime={
376
+ "gte": datetime_search["eq"],
377
+ },
378
+ ),
379
+ ],
380
+ ),
381
+ ]
350
382
  )
383
+
384
+ # If the request is a date range return
385
+ # items with datetimes within the requested date range OR
386
+ # their startdatetime ithin the requested date range OR
387
+ # their enddatetime ithin the requested date range OR
388
+ # the requested daterange within their start and end datetimes
351
389
  else:
352
- search = search.filter(
353
- "range", properties__datetime={"lte": datetime_search["lte"]}
354
- )
355
- search = search.filter(
356
- "range", properties__datetime={"gte": datetime_search["gte"]}
390
+ should.extend(
391
+ [
392
+ Q(
393
+ "bool",
394
+ filter=[
395
+ Q(
396
+ "range",
397
+ properties__datetime={
398
+ "gte": datetime_search["gte"],
399
+ "lte": datetime_search["lte"],
400
+ },
401
+ ),
402
+ ],
403
+ ),
404
+ Q(
405
+ "bool",
406
+ filter=[
407
+ Q(
408
+ "range",
409
+ properties__start_datetime={
410
+ "gte": datetime_search["gte"],
411
+ "lte": datetime_search["lte"],
412
+ },
413
+ ),
414
+ ],
415
+ ),
416
+ Q(
417
+ "bool",
418
+ filter=[
419
+ Q(
420
+ "range",
421
+ properties__end_datetime={
422
+ "gte": datetime_search["gte"],
423
+ "lte": datetime_search["lte"],
424
+ },
425
+ ),
426
+ ],
427
+ ),
428
+ Q(
429
+ "bool",
430
+ filter=[
431
+ Q(
432
+ "range",
433
+ properties__start_datetime={
434
+ "lte": datetime_search["gte"]
435
+ },
436
+ ),
437
+ Q(
438
+ "range",
439
+ properties__end_datetime={
440
+ "gte": datetime_search["lte"]
441
+ },
442
+ ),
443
+ ],
444
+ ),
445
+ ]
357
446
  )
447
+
448
+ search = search.query(Q("bool", filter=[Q("bool", should=should)]))
449
+
358
450
  return search
359
451
 
360
452
  @staticmethod
@@ -639,7 +731,7 @@ class DatabaseLogic(BaseDatabaseLogic):
639
731
  if not await self.client.exists(index=COLLECTIONS_INDEX, id=collection_id):
640
732
  raise NotFoundError(f"Collection {collection_id} does not exist")
641
733
 
642
- async def prep_create_item(
734
+ async def async_prep_create_item(
643
735
  self, item: Item, base_url: str, exist_ok: bool = False
644
736
  ) -> Item:
645
737
  """
@@ -669,44 +761,113 @@ class DatabaseLogic(BaseDatabaseLogic):
669
761
 
670
762
  return self.item_serializer.stac_to_db(item, base_url)
671
763
 
672
- def sync_prep_create_item(
764
+ async def bulk_async_prep_create_item(
673
765
  self, item: Item, base_url: str, exist_ok: bool = False
674
766
  ) -> Item:
675
767
  """
676
768
  Prepare an item for insertion into the database.
677
769
 
678
- This method performs pre-insertion preparation on the given `item`,
679
- such as checking if the collection the item belongs to exists,
680
- and optionally verifying that an item with the same ID does not already exist in the database.
770
+ This method performs pre-insertion preparation on the given `item`, such as:
771
+ - Verifying that the collection the item belongs to exists.
772
+ - Optionally checking if an item with the same ID already exists in the database.
773
+ - Serializing the item into a database-compatible format.
681
774
 
682
775
  Args:
683
- item (Item): The item to be inserted into the database.
684
- base_url (str): The base URL used for constructing URLs for the item.
685
- exist_ok (bool): Indicates whether the item can exist already.
776
+ item (Item): The item to be prepared for insertion.
777
+ base_url (str): The base URL used to construct the item's self URL.
778
+ exist_ok (bool): Indicates whether the item can already exist in the database.
779
+ If False, a `ConflictError` is raised if the item exists.
686
780
 
687
781
  Returns:
688
- Item: The item after preparation is done.
782
+ Item: The prepared item, serialized into a database-compatible format.
689
783
 
690
784
  Raises:
691
785
  NotFoundError: If the collection that the item belongs to does not exist in the database.
692
- ConflictError: If an item with the same ID already exists in the collection.
786
+ ConflictError: If an item with the same ID already exists in the collection and `exist_ok` is False,
787
+ and `RAISE_ON_BULK_ERROR` is set to `true`.
693
788
  """
694
- item_id = item["id"]
695
- collection_id = item["collection"]
696
- if not self.sync_client.exists(index=COLLECTIONS_INDEX, id=collection_id):
697
- raise NotFoundError(f"Collection {collection_id} does not exist")
789
+ logger.debug(f"Preparing item {item['id']} in collection {item['collection']}.")
698
790
 
699
- if not exist_ok and self.sync_client.exists(
700
- index=index_alias_by_collection_id(collection_id),
701
- id=mk_item_id(item_id, collection_id),
791
+ # Check if the collection exists
792
+ await self.check_collection_exists(collection_id=item["collection"])
793
+
794
+ # Check if the item already exists in the database
795
+ if not exist_ok and await self.client.exists(
796
+ index=index_alias_by_collection_id(item["collection"]),
797
+ id=mk_item_id(item["id"], item["collection"]),
702
798
  ):
703
- raise ConflictError(
704
- f"Item {item_id} in collection {collection_id} already exists"
799
+ error_message = (
800
+ f"Item {item['id']} in collection {item['collection']} already exists."
705
801
  )
802
+ if self.async_settings.raise_on_bulk_error:
803
+ raise ConflictError(error_message)
804
+ else:
805
+ logger.warning(
806
+ f"{error_message} Continuing as `RAISE_ON_BULK_ERROR` is set to false."
807
+ )
808
+ # Serialize the item into a database-compatible format
809
+ prepped_item = self.item_serializer.stac_to_db(item, base_url)
810
+ logger.debug(f"Item {item['id']} prepared successfully.")
811
+ return prepped_item
812
+
813
+ def bulk_sync_prep_create_item(
814
+ self, item: Item, base_url: str, exist_ok: bool = False
815
+ ) -> Item:
816
+ """
817
+ Prepare an item for insertion into the database.
706
818
 
707
- return self.item_serializer.stac_to_db(item, base_url)
819
+ This method performs pre-insertion preparation on the given `item`, such as:
820
+ - Verifying that the collection the item belongs to exists.
821
+ - Optionally checking if an item with the same ID already exists in the database.
822
+ - Serializing the item into a database-compatible format.
708
823
 
709
- async def create_item(self, item: Item, refresh: bool = False):
824
+ Args:
825
+ item (Item): The item to be prepared for insertion.
826
+ base_url (str): The base URL used to construct the item's self URL.
827
+ exist_ok (bool): Indicates whether the item can already exist in the database.
828
+ If False, a `ConflictError` is raised if the item exists.
829
+
830
+ Returns:
831
+ Item: The prepared item, serialized into a database-compatible format.
832
+
833
+ Raises:
834
+ NotFoundError: If the collection that the item belongs to does not exist in the database.
835
+ ConflictError: If an item with the same ID already exists in the collection and `exist_ok` is False,
836
+ and `RAISE_ON_BULK_ERROR` is set to `true`.
837
+ """
838
+ logger.debug(f"Preparing item {item['id']} in collection {item['collection']}.")
839
+
840
+ # Check if the collection exists
841
+ if not self.sync_client.exists(index=COLLECTIONS_INDEX, id=item["collection"]):
842
+ raise NotFoundError(f"Collection {item['collection']} does not exist")
843
+
844
+ # Check if the item already exists in the database
845
+ if not exist_ok and self.sync_client.exists(
846
+ index=index_alias_by_collection_id(item["collection"]),
847
+ id=mk_item_id(item["id"], item["collection"]),
848
+ ):
849
+ error_message = (
850
+ f"Item {item['id']} in collection {item['collection']} already exists."
851
+ )
852
+ if self.sync_settings.raise_on_bulk_error:
853
+ raise ConflictError(error_message)
854
+ else:
855
+ logger.warning(
856
+ f"{error_message} Continuing as `RAISE_ON_BULK_ERROR` is set to false."
857
+ )
858
+
859
+ # Serialize the item into a database-compatible format
860
+ prepped_item = self.item_serializer.stac_to_db(item, base_url)
861
+ logger.debug(f"Item {item['id']} prepared successfully.")
862
+ return prepped_item
863
+
864
+ async def create_item(
865
+ self,
866
+ item: Item,
867
+ refresh: bool = False,
868
+ base_url: str = "",
869
+ exist_ok: bool = False,
870
+ ):
710
871
  """Database logic for creating one item.
711
872
 
712
873
  Args:
@@ -722,18 +883,16 @@ class DatabaseLogic(BaseDatabaseLogic):
722
883
  # todo: check if collection exists, but cache
723
884
  item_id = item["id"]
724
885
  collection_id = item["collection"]
725
- es_resp = await self.client.index(
886
+ item = await self.async_prep_create_item(
887
+ item=item, base_url=base_url, exist_ok=exist_ok
888
+ )
889
+ await self.client.index(
726
890
  index=index_alias_by_collection_id(collection_id),
727
891
  id=mk_item_id(item_id, collection_id),
728
892
  body=item,
729
893
  refresh=refresh,
730
894
  )
731
895
 
732
- if (meta := es_resp.get("meta")) and meta.get("status") == 409:
733
- raise ConflictError(
734
- f"Item {item_id} in collection {collection_id} already exists"
735
- )
736
-
737
896
  async def delete_item(
738
897
  self, item_id: str, collection_id: str, refresh: bool = False
739
898
  ):
@@ -899,52 +1058,72 @@ class DatabaseLogic(BaseDatabaseLogic):
899
1058
  await delete_item_index(collection_id)
900
1059
 
901
1060
  async def bulk_async(
902
- self, collection_id: str, processed_items: List[Item], refresh: bool = False
903
- ) -> None:
904
- """Perform a bulk insert of items into the database asynchronously.
1061
+ self,
1062
+ collection_id: str,
1063
+ processed_items: List[Item],
1064
+ refresh: bool = False,
1065
+ ) -> Tuple[int, List[Dict[str, Any]]]:
1066
+ """
1067
+ Perform a bulk insert of items into the database asynchronously.
905
1068
 
906
1069
  Args:
907
- self: The instance of the object calling this function.
908
1070
  collection_id (str): The ID of the collection to which the items belong.
909
1071
  processed_items (List[Item]): A list of `Item` objects to be inserted into the database.
910
1072
  refresh (bool): Whether to refresh the index after the bulk insert (default: False).
911
1073
 
1074
+ Returns:
1075
+ Tuple[int, List[Dict[str, Any]]]: A tuple containing:
1076
+ - The number of successfully processed actions (`success`).
1077
+ - A list of errors encountered during the bulk operation (`errors`).
1078
+
912
1079
  Notes:
913
- This function performs a bulk insert of `processed_items` into the database using the specified `collection_id`. The
914
- insert is performed asynchronously, and the event loop is used to run the operation in a separate executor. The
915
- `mk_actions` function is called to generate a list of actions for the bulk insert. If `refresh` is set to True, the
916
- index is refreshed after the bulk insert. The function does not return any value.
1080
+ This function performs a bulk insert of `processed_items` into the database using the specified `collection_id`.
1081
+ The insert is performed asynchronously, and the event loop is used to run the operation in a separate executor.
1082
+ The `mk_actions` function is called to generate a list of actions for the bulk insert. If `refresh` is set to True,
1083
+ the index is refreshed after the bulk insert.
917
1084
  """
918
- await helpers.async_bulk(
1085
+ raise_on_error = self.async_settings.raise_on_bulk_error
1086
+ success, errors = await helpers.async_bulk(
919
1087
  self.client,
920
1088
  mk_actions(collection_id, processed_items),
921
1089
  refresh=refresh,
922
- raise_on_error=False,
1090
+ raise_on_error=raise_on_error,
923
1091
  )
1092
+ return success, errors
924
1093
 
925
1094
  def bulk_sync(
926
- self, collection_id: str, processed_items: List[Item], refresh: bool = False
927
- ) -> None:
928
- """Perform a bulk insert of items into the database synchronously.
1095
+ self,
1096
+ collection_id: str,
1097
+ processed_items: List[Item],
1098
+ refresh: bool = False,
1099
+ ) -> Tuple[int, List[Dict[str, Any]]]:
1100
+ """
1101
+ Perform a bulk insert of items into the database synchronously.
929
1102
 
930
1103
  Args:
931
- self: The instance of the object calling this function.
932
1104
  collection_id (str): The ID of the collection to which the items belong.
933
1105
  processed_items (List[Item]): A list of `Item` objects to be inserted into the database.
934
1106
  refresh (bool): Whether to refresh the index after the bulk insert (default: False).
935
1107
 
1108
+ Returns:
1109
+ Tuple[int, List[Dict[str, Any]]]: A tuple containing:
1110
+ - The number of successfully processed actions (`success`).
1111
+ - A list of errors encountered during the bulk operation (`errors`).
1112
+
936
1113
  Notes:
937
- This function performs a bulk insert of `processed_items` into the database using the specified `collection_id`. The
938
- insert is performed synchronously and blocking, meaning that the function does not return until the insert has
1114
+ This function performs a bulk insert of `processed_items` into the database using the specified `collection_id`.
1115
+ The insert is performed synchronously and blocking, meaning that the function does not return until the insert has
939
1116
  completed. The `mk_actions` function is called to generate a list of actions for the bulk insert. If `refresh` is set to
940
- True, the index is refreshed after the bulk insert. The function does not return any value.
1117
+ True, the index is refreshed after the bulk insert.
941
1118
  """
942
- helpers.bulk(
1119
+ raise_on_error = self.sync_settings.raise_on_bulk_error
1120
+ success, errors = helpers.bulk(
943
1121
  self.sync_client,
944
1122
  mk_actions(collection_id, processed_items),
945
1123
  refresh=refresh,
946
- raise_on_error=False,
1124
+ raise_on_error=raise_on_error,
947
1125
  )
1126
+ return success, errors
948
1127
 
949
1128
  # DANGER
950
1129
  async def delete_items(self) -> None:
@@ -0,0 +1,2 @@
1
+ """library version."""
2
+ __version__ = "4.1.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: stac-fastapi-opensearch
3
- Version: 4.0.0a1
3
+ Version: 4.1.0
4
4
  Summary: Opensearch stac-fastapi backend.
5
5
  Home-page: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch
6
6
  License: MIT
@@ -50,8 +50,18 @@ Provides-Extra: server
50
50
  - There is [Postman](https://documenter.getpostman.com/view/12888943/2s8ZDSdRHA) documentation here for examples on how to run some of the API routes locally - after starting the elasticsearch backend via the compose.yml file.
51
51
  - The `/examples` folder shows an example of running stac-fastapi-elasticsearch from PyPI in docker without needing any code from the repository. There is also a Postman collection here that you can load into Postman for testing the API routes.
52
52
 
53
- - For changes, see the [Changelog](CHANGELOG.md)
54
- - We are always welcoming contributions. For the development notes: [Contributing](CONTRIBUTING.md)
53
+
54
+ ### Performance Note
55
+
56
+ The `enable_direct_response` option is provided by the stac-fastapi core library (introduced in stac-fastapi 5.2.0) and is available in this project starting from v4.0.0.
57
+
58
+ **You can now control this setting via the `ENABLE_DIRECT_RESPONSE` environment variable.**
59
+
60
+ When enabled (`ENABLE_DIRECT_RESPONSE=true`), endpoints return Starlette Response objects directly, bypassing FastAPI's default serialization for improved performance. **However, all FastAPI dependencies (including authentication, custom status codes, and validation) are disabled for all routes.**
61
+
62
+ This mode is best suited for public or read-only APIs where authentication and custom logic are not required. Default is `false` for safety.
63
+
64
+ See: [issue #347](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/issues/347)
55
65
 
56
66
 
57
67
  ### To install from PyPI:
@@ -95,8 +105,9 @@ If you wish to use a different version, put the following in a
95
105
  file named `.env` in the same directory you run Docker Compose from:
96
106
 
97
107
  ```shell
98
- ELASTICSEARCH_VERSION=7.17.1
99
- OPENSEARCH_VERSION=2.11.0
108
+ ELASTICSEARCH_VERSION=8.11.0
109
+ OPENSEARCH_VERSION=2.11.1
110
+ ENABLE_DIRECT_RESPONSE=false
100
111
  ```
101
112
  The most recent Elasticsearch 7.x versions should also work. See the [opensearch-py docs](https://github.com/opensearch-project/opensearch-py/blob/main/COMPATIBILITY.md) for compatibility information.
102
113
 
@@ -121,8 +132,10 @@ You can customize additional settings in your `.env` file:
121
132
  | `RELOAD` | Enable auto-reload for development. | `true` | Optional |
122
133
  | `STAC_FASTAPI_RATE_LIMIT` | API rate limit per client. | `200/minute` | Optional |
123
134
  | `BACKEND` | Tests-related variable | `elasticsearch` or `opensearch` based on the backend | Optional |
124
- | `ELASTICSEARCH_VERSION` | ElasticSearch version | `7.17.1` | Optional |
125
- | `OPENSEARCH_VERSION` | OpenSearch version | `2.11.0` | Optional |
135
+ | `ELASTICSEARCH_VERSION` | Version of Elasticsearch to use. | `8.11.0` | Optional |
136
+ | `ENABLE_DIRECT_RESPONSE` | Enable direct response for maximum performance (disables all FastAPI dependencies, including authentication, custom status codes, and validation) | `false` | Optional |
137
+ | `OPENSEARCH_VERSION` | OpenSearch version | `2.11.1` | Optional
138
+ | `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 |
126
139
 
127
140
  > [!NOTE]
128
141
  > The variables `ES_HOST`, `ES_PORT`, `ES_USE_SSL`, and `ES_VERIFY_CERTS` 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.
@@ -1,4 +1,4 @@
1
- stac-fastapi-core==4.0.0a1
1
+ stac-fastapi-core==4.1.0
2
2
  opensearch-py~=2.8.0
3
3
  opensearch-py[async]~=2.8.0
4
4
  uvicorn~=0.23.0
@@ -1,2 +0,0 @@
1
- """library version."""
2
- __version__ = "4.0.0a1"