stac-fastapi-core 6.6.0__tar.gz → 6.7.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 (29) hide show
  1. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/PKG-INFO +14 -1
  2. stac_fastapi_core-6.7.0/README.md +32 -0
  3. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/pyproject.toml +1 -0
  4. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/stac_fastapi/core/core.py +40 -2
  5. stac_fastapi_core-6.7.0/stac_fastapi/core/extensions/collections_search.py +384 -0
  6. stac_fastapi_core-6.7.0/stac_fastapi/core/redis_utils.py +301 -0
  7. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/stac_fastapi/core/version.py +1 -1
  8. stac_fastapi_core-6.6.0/README.md +0 -20
  9. stac_fastapi_core-6.6.0/stac_fastapi/core/extensions/collections_search.py +0 -194
  10. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/.gitignore +0 -0
  11. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/pytest.ini +0 -0
  12. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/stac_fastapi/core/__init__.py +0 -0
  13. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/stac_fastapi/core/base_database_logic.py +0 -0
  14. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/stac_fastapi/core/base_settings.py +0 -0
  15. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/stac_fastapi/core/basic_auth.py +0 -0
  16. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/stac_fastapi/core/datetime_utils.py +0 -0
  17. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/stac_fastapi/core/extensions/__init__.py +0 -0
  18. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/stac_fastapi/core/extensions/aggregation.py +0 -0
  19. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/stac_fastapi/core/extensions/fields.py +0 -0
  20. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/stac_fastapi/core/extensions/filter.py +0 -0
  21. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/stac_fastapi/core/extensions/query.py +0 -0
  22. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/stac_fastapi/core/models/__init__.py +0 -0
  23. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/stac_fastapi/core/models/links.py +0 -0
  24. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/stac_fastapi/core/models/search.py +0 -0
  25. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/stac_fastapi/core/rate_limit.py +0 -0
  26. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/stac_fastapi/core/route_dependencies.py +0 -0
  27. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/stac_fastapi/core/serializers.py +0 -0
  28. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/stac_fastapi/core/session.py +0 -0
  29. {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.0}/stac_fastapi/core/utilities.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stac_fastapi_core
3
- Version: 6.6.0
3
+ Version: 6.7.0
4
4
  Summary: Core library for the Elasticsearch and Opensearch stac-fastapi backends.
5
5
  Project-URL: Homepage, https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch
6
6
  License: MIT
@@ -24,6 +24,7 @@ Requires-Dist: orjson~=3.11.0
24
24
  Requires-Dist: overrides~=7.4.0
25
25
  Requires-Dist: pydantic<3.0.0,>=2.4.1
26
26
  Requires-Dist: pygeofilter~=0.3.1
27
+ Requires-Dist: redis==6.4.0
27
28
  Requires-Dist: slowapi~=0.1.9
28
29
  Requires-Dist: stac-fastapi-api==6.0.0
29
30
  Requires-Dist: stac-fastapi-extensions==6.0.0
@@ -33,6 +34,18 @@ Description-Content-Type: text/markdown
33
34
 
34
35
  # stac-fastapi-core
35
36
 
37
+ <p align="left">
38
+ <img src="https://raw.githubusercontent.com/stac-utils/stac-fastapi-elasticsearch-opensearch/refs/heads/main/assets/sfeos.png" width=1000>
39
+ </p>
40
+
41
+ [![Downloads](https://static.pepy.tech/badge/stac-fastapi-core?color=blue)](https://pepy.tech/project/stac-fastapi-core)
42
+ [![GitHub contributors](https://img.shields.io/github/contributors/stac-utils/stac-fastapi-elasticsearch-opensearch?color=blue)](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/graphs/contributors)
43
+ [![GitHub stars](https://img.shields.io/github/stars/stac-utils/stac-fastapi-elasticsearch-opensearch.svg?color=blue)](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/stargazers)
44
+ [![GitHub forks](https://img.shields.io/github/forks/stac-utils/stac-fastapi-elasticsearch-opensearch.svg?color=blue)](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/network/members)
45
+ [![PyPI version](https://img.shields.io/pypi/v/stac-fastapi-elasticsearch.svg?color=blue)](https://pypi.org/project/stac-fastapi-elasticsearch/)
46
+ [![STAC](https://img.shields.io/badge/STAC-1.1.0-blue.svg)](https://github.com/radiantearth/stac-spec/tree/v1.1.0)
47
+ [![stac-fastapi](https://img.shields.io/badge/stac--fastapi-6.0.0-blue.svg)](https://github.com/stac-utils/stac-fastapi)
48
+
36
49
  Core functionality for stac-fastapi. For full documentation, please see the [main README](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/blob/main/README.md).
37
50
 
38
51
  ## Package Information
@@ -0,0 +1,32 @@
1
+ # stac-fastapi-core
2
+
3
+ <p align="left">
4
+ <img src="https://raw.githubusercontent.com/stac-utils/stac-fastapi-elasticsearch-opensearch/refs/heads/main/assets/sfeos.png" width=1000>
5
+ </p>
6
+
7
+ [![Downloads](https://static.pepy.tech/badge/stac-fastapi-core?color=blue)](https://pepy.tech/project/stac-fastapi-core)
8
+ [![GitHub contributors](https://img.shields.io/github/contributors/stac-utils/stac-fastapi-elasticsearch-opensearch?color=blue)](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/graphs/contributors)
9
+ [![GitHub stars](https://img.shields.io/github/stars/stac-utils/stac-fastapi-elasticsearch-opensearch.svg?color=blue)](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/stargazers)
10
+ [![GitHub forks](https://img.shields.io/github/forks/stac-utils/stac-fastapi-elasticsearch-opensearch.svg?color=blue)](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/network/members)
11
+ [![PyPI version](https://img.shields.io/pypi/v/stac-fastapi-elasticsearch.svg?color=blue)](https://pypi.org/project/stac-fastapi-elasticsearch/)
12
+ [![STAC](https://img.shields.io/badge/STAC-1.1.0-blue.svg)](https://github.com/radiantearth/stac-spec/tree/v1.1.0)
13
+ [![stac-fastapi](https://img.shields.io/badge/stac--fastapi-6.0.0-blue.svg)](https://github.com/stac-utils/stac-fastapi)
14
+
15
+ Core functionality for stac-fastapi. For full documentation, please see the [main README](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/blob/main/README.md).
16
+
17
+ ## Package Information
18
+
19
+ - **Package name**: stac-fastapi-core
20
+ - **Description**: Core functionality for STAC API implementations.
21
+ - **Documentation**: [https://stac-utils.github.io/stac-fastapi-elasticsearch-opensearch/](https://stac-utils.github.io/stac-fastapi-elasticsearch-opensearch/)
22
+ - **Source**: [GitHub Repository](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/)
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ pip install stac-fastapi-core
28
+ ```
29
+
30
+ ## Quick Start
31
+
32
+ For detailed usage and examples, please refer to the [main documentation](https://stac-utils.github.io/stac-fastapi-elasticsearch-opensearch/).
@@ -44,6 +44,7 @@ dependencies = [
44
44
  "pygeofilter~=0.3.1",
45
45
  "jsonschema~=4.0.0",
46
46
  "slowapi~=0.1.9",
47
+ "redis==6.4.0",
47
48
  ]
48
49
 
49
50
  [project.urls]
@@ -24,9 +24,10 @@ from stac_fastapi.core.base_database_logic import BaseDatabaseLogic
24
24
  from stac_fastapi.core.base_settings import ApiBaseSettings
25
25
  from stac_fastapi.core.datetime_utils import format_datetime_range
26
26
  from stac_fastapi.core.models.links import PagingLinks
27
+ from stac_fastapi.core.redis_utils import redis_pagination_links
27
28
  from stac_fastapi.core.serializers import CollectionSerializer, ItemSerializer
28
29
  from stac_fastapi.core.session import Session
29
- from stac_fastapi.core.utilities import filter_fields
30
+ from stac_fastapi.core.utilities import filter_fields, get_bool_env
30
31
  from stac_fastapi.extensions.core.transaction import AsyncBaseTransactionsClient
31
32
  from stac_fastapi.extensions.core.transaction.request import (
32
33
  PartialCollection,
@@ -262,6 +263,7 @@ class CoreClient(AsyncBaseCoreClient):
262
263
  A Collections object containing all the collections in the database and links to various resources.
263
264
  """
264
265
  base_url = str(request.base_url)
266
+ redis_enable = get_bool_env("REDIS_ENABLE", default=False)
265
267
 
266
268
  global_max_limit = (
267
269
  int(os.getenv("STAC_GLOBAL_COLLECTION_MAX_LIMIT"))
@@ -417,6 +419,14 @@ class CoreClient(AsyncBaseCoreClient):
417
419
  },
418
420
  ]
419
421
 
422
+ if redis_enable:
423
+ await redis_pagination_links(
424
+ current_url=str(request.url),
425
+ token=token,
426
+ next_token=next_token,
427
+ links=links,
428
+ )
429
+
420
430
  if next_token:
421
431
  next_link = PagingLinks(next=next_token, request=request).link_next()
422
432
  links.append(next_link)
@@ -761,8 +771,8 @@ class CoreClient(AsyncBaseCoreClient):
761
771
  search_request.limit = limit
762
772
 
763
773
  base_url = str(request.base_url)
764
-
765
774
  search = self.database.make_search()
775
+ redis_enable = get_bool_env("REDIS_ENABLE", default=False)
766
776
 
767
777
  if search_request.ids:
768
778
  search = self.database.apply_ids_filter(
@@ -866,6 +876,34 @@ class CoreClient(AsyncBaseCoreClient):
866
876
  ]
867
877
  links = await PagingLinks(request=request, next=next_token).get_links()
868
878
 
879
+ collection_links = []
880
+ # Add "collection" and "parent" rels only for /collections/{collection_id}/items
881
+ if search_request.collections and "/items" in str(request.url):
882
+ for collection_id in search_request.collections:
883
+ collection_links.extend(
884
+ [
885
+ {
886
+ "rel": "collection",
887
+ "type": "application/json",
888
+ "href": urljoin(base_url, f"collections/{collection_id}"),
889
+ },
890
+ {
891
+ "rel": "parent",
892
+ "type": "application/json",
893
+ "href": urljoin(base_url, f"collections/{collection_id}"),
894
+ },
895
+ ]
896
+ )
897
+ links.extend(collection_links)
898
+
899
+ if redis_enable:
900
+ await redis_pagination_links(
901
+ current_url=str(request.url),
902
+ token=token_param,
903
+ next_token=next_token,
904
+ links=links,
905
+ )
906
+
869
907
  return stac_types.ItemCollection(
870
908
  type="FeatureCollection",
871
909
  features=items,
@@ -0,0 +1,384 @@
1
+ """Collections search extension."""
2
+
3
+ from typing import Any, Dict, List, Optional, Type, Union
4
+
5
+ from fastapi import APIRouter, Body, FastAPI, Query, Request
6
+ from fastapi.responses import JSONResponse
7
+ from pydantic import BaseModel
8
+ from stac_pydantic.api.search import ExtendedSearch
9
+ from starlette.responses import Response
10
+
11
+ from stac_fastapi.api.models import APIRequest
12
+ from stac_fastapi.types.core import BaseCoreClient
13
+ from stac_fastapi.types.extension import ApiExtension
14
+ from stac_fastapi.types.stac import Collections
15
+
16
+
17
+ class CollectionsSearchRequest(ExtendedSearch):
18
+ """Extended search model for collections with free text search support."""
19
+
20
+ q: Optional[Union[str, List[str]]] = None
21
+ token: Optional[str] = None
22
+ query: Optional[
23
+ str
24
+ ] = None # Legacy query extension (deprecated but still supported)
25
+ filter_expr: Optional[str] = None
26
+ filter_lang: Optional[str] = None
27
+
28
+
29
+ def build_get_collections_search_doc(original_endpoint):
30
+ """Return a documented GET endpoint wrapper for /collections-search."""
31
+
32
+ async def documented_endpoint(
33
+ request: Request,
34
+ q: Optional[Union[str, List[str]]] = Query(
35
+ None,
36
+ description="Free text search query",
37
+ ),
38
+ query: Optional[str] = Query(
39
+ None,
40
+ description="Additional filtering expressed as a string (legacy support)",
41
+ example="platform=landsat AND collection_category=level2",
42
+ ),
43
+ limit: int = Query(
44
+ 10,
45
+ ge=1,
46
+ description=(
47
+ "The maximum number of collections to return (page size). Defaults to 10."
48
+ ),
49
+ ),
50
+ token: Optional[str] = Query(
51
+ None,
52
+ description="Pagination token for the next page of results",
53
+ ),
54
+ bbox: Optional[str] = Query(
55
+ None,
56
+ description=(
57
+ "Bounding box for spatial filtering in format 'minx,miny,maxx,maxy' "
58
+ "or 'minx,miny,minz,maxx,maxy,maxz'"
59
+ ),
60
+ ),
61
+ datetime: Optional[str] = Query(
62
+ None,
63
+ description=(
64
+ "Temporal filter in ISO 8601 format (e.g., "
65
+ "'2020-01-01T00:00:00Z/2021-01-01T00:00:00Z')"
66
+ ),
67
+ ),
68
+ sortby: Optional[str] = Query(
69
+ None,
70
+ description=(
71
+ "Sorting criteria in the format 'field' or '-field' for descending order"
72
+ ),
73
+ ),
74
+ fields: Optional[List[str]] = Query(
75
+ None,
76
+ description=(
77
+ "Comma-separated list of fields to include or exclude (use -field to exclude)"
78
+ ),
79
+ alias="fields[]",
80
+ ),
81
+ filter: Optional[str] = Query(
82
+ None,
83
+ description=(
84
+ "Structured filter expression in CQL2 JSON or CQL2-text format"
85
+ ),
86
+ example='{"op": "=", "args": [{"property": "properties.category"}, "level2"]}',
87
+ ),
88
+ filter_lang: Optional[str] = Query(
89
+ None,
90
+ description=(
91
+ "Filter language. Must be 'cql2-json' or 'cql2-text' if specified"
92
+ ),
93
+ example="cql2-json",
94
+ ),
95
+ ):
96
+ # Delegate to original endpoint with parameters
97
+ # Since FastAPI extracts parameters from the URL when they're defined as function parameters,
98
+ # we need to create a request wrapper that provides our modified query_params
99
+
100
+ # Create a mutable copy of query_params
101
+ if hasattr(request, "_query_params"):
102
+ query_params = dict(request._query_params)
103
+ else:
104
+ query_params = dict(request.query_params)
105
+
106
+ # Add q parameter back to query_params if it was provided
107
+ # Convert to list format to match /collections behavior
108
+ if q is not None:
109
+ if isinstance(q, str):
110
+ # Single string should become a list with one element
111
+ query_params["q"] = [q]
112
+ elif isinstance(q, list):
113
+ # Already a list, use as-is
114
+ query_params["q"] = q
115
+
116
+ # Add filter parameters back to query_params if they were provided
117
+ if filter is not None:
118
+ query_params["filter"] = filter
119
+ if filter_lang is not None:
120
+ query_params["filter-lang"] = filter_lang
121
+
122
+ # Create a request wrapper that provides our modified query_params
123
+ class RequestWrapper:
124
+ def __init__(self, original_request, modified_query_params):
125
+ self._original = original_request
126
+ self._query_params = modified_query_params
127
+
128
+ @property
129
+ def query_params(self):
130
+ return self._query_params
131
+
132
+ def __getattr__(self, name):
133
+ # Delegate all other attributes to the original request
134
+ return getattr(self._original, name)
135
+
136
+ wrapped_request = RequestWrapper(request, query_params)
137
+ return await original_endpoint(wrapped_request)
138
+
139
+ documented_endpoint.__name__ = original_endpoint.__name__
140
+ return documented_endpoint
141
+
142
+
143
+ def build_post_collections_search_doc(original_post_endpoint):
144
+ """Return a documented POST endpoint wrapper for /collections-search."""
145
+
146
+ async def documented_post_endpoint(
147
+ request: Request,
148
+ body: Dict[str, Any] = Body(
149
+ ...,
150
+ description=(
151
+ "Search parameters for collections.\n\n"
152
+ "- `q`: Free text search query (string or list of strings)\n"
153
+ "- `query`: Additional filtering expressed as a string (legacy support)\n"
154
+ "- `filter`: Structured filter expression in CQL2 JSON or CQL2-text format\n"
155
+ "- `filter_lang`: Filter language. Must be 'cql2-json' or 'cql2-text' if specified\n"
156
+ "- `limit`: Maximum number of results to return (default: 10)\n"
157
+ "- `token`: Pagination token for the next page of results\n"
158
+ "- `bbox`: Bounding box [minx, miny, maxx, maxy] or [minx, miny, minz, maxx, maxy, maxz]\n"
159
+ "- `datetime`: Temporal filter in ISO 8601 (e.g., '2020-01-01T00:00:00Z/2021-01-01T12:31:12Z')\n"
160
+ "- `sortby`: List of sort criteria objects with 'field' and 'direction' (asc/desc)\n"
161
+ "- `fields`: Object with 'include' and 'exclude' arrays for field selection"
162
+ ),
163
+ example={
164
+ "q": "landsat",
165
+ "query": "platform=landsat AND collection_category=level2",
166
+ "filter": {
167
+ "op": "=",
168
+ "args": [{"property": "properties.category"}, "level2"],
169
+ },
170
+ "filter_lang": "cql2-json",
171
+ "limit": 10,
172
+ "token": "next-page-token",
173
+ "bbox": [-180, -90, 180, 90],
174
+ "datetime": "2020-01-01T00:00:00Z/2021-01-01T12:31:12Z",
175
+ "sortby": [{"field": "id", "direction": "asc"}],
176
+ "fields": {
177
+ "include": ["id", "title", "description"],
178
+ "exclude": ["properties"],
179
+ },
180
+ },
181
+ ),
182
+ ) -> Union[Collections, Response]:
183
+ return await original_post_endpoint(request, body)
184
+
185
+ documented_post_endpoint.__name__ = original_post_endpoint.__name__
186
+ return documented_post_endpoint
187
+
188
+
189
+ class CollectionsSearchEndpointExtension(ApiExtension):
190
+ """Collections search endpoint extension.
191
+
192
+ This extension adds a dedicated /collections-search endpoint for collection search operations.
193
+ """
194
+
195
+ def __init__(
196
+ self,
197
+ client: Optional[BaseCoreClient] = None,
198
+ settings: dict = None,
199
+ GET: Optional[Type[Union[BaseModel, APIRequest]]] = None,
200
+ POST: Optional[Type[Union[BaseModel, APIRequest]]] = None,
201
+ conformance_classes: Optional[List[str]] = None,
202
+ ):
203
+ """Initialize the extension.
204
+
205
+ Args:
206
+ client: Optional BaseCoreClient instance to use for this extension.
207
+ settings: Dictionary of settings to pass to the extension.
208
+ GET: Optional GET request model.
209
+ POST: Optional POST request model.
210
+ conformance_classes: Optional list of conformance classes to add to the API.
211
+ """
212
+ super().__init__()
213
+ self.client = client
214
+ self.settings = settings or {}
215
+ self.GET = GET
216
+ self.POST = POST
217
+ self.conformance_classes = conformance_classes or []
218
+ self.router = APIRouter()
219
+
220
+ def register(self, app: FastAPI) -> None:
221
+ """Register the extension with a FastAPI application.
222
+
223
+ Args:
224
+ app: target FastAPI application.
225
+
226
+ Returns:
227
+ None
228
+ """
229
+ # Remove any existing routes to avoid duplicates
230
+ self.router.routes = []
231
+
232
+ # Recreate endpoints with proper OpenAPI documentation
233
+ if self.GET:
234
+ original_endpoint = self.collections_search_get_endpoint
235
+ documented_endpoint = build_get_collections_search_doc(original_endpoint)
236
+
237
+ self.router.add_api_route(
238
+ path="/collections-search",
239
+ endpoint=documented_endpoint,
240
+ response_model=None,
241
+ response_class=JSONResponse,
242
+ methods=["GET"],
243
+ summary="Search collections",
244
+ description=(
245
+ "Search for collections using query parameters. "
246
+ "Supports filtering, sorting, and field selection."
247
+ ),
248
+ response_description="A list of collections matching the search criteria",
249
+ tags=["Collections Search Extension"],
250
+ **(self.settings if isinstance(self.settings, dict) else {}),
251
+ )
252
+
253
+ if self.POST:
254
+ original_post_endpoint = self.collections_search_post_endpoint
255
+ documented_post_endpoint = build_post_collections_search_doc(
256
+ original_post_endpoint
257
+ )
258
+
259
+ self.router.add_api_route(
260
+ path="/collections-search",
261
+ endpoint=documented_post_endpoint,
262
+ response_model=None,
263
+ response_class=JSONResponse,
264
+ methods=["POST"],
265
+ summary="Search collections",
266
+ description=(
267
+ "Search for collections using a JSON request body. "
268
+ "Supports filtering, sorting, field selection, and pagination."
269
+ ),
270
+ tags=["Collections Search Extension"],
271
+ **(self.settings if isinstance(self.settings, dict) else {}),
272
+ )
273
+
274
+ app.include_router(self.router)
275
+
276
+ async def collections_search_get_endpoint(
277
+ self, request: Request
278
+ ) -> Union[Collections, Response]:
279
+ """GET /collections-search endpoint.
280
+
281
+ Args:
282
+ request: Request object.
283
+
284
+ Returns:
285
+ Collections: Collections object.
286
+ """
287
+ # Extract query parameters from the request
288
+ params = dict(request.query_params)
289
+
290
+ # Convert query parameters to appropriate types
291
+ if "limit" in params:
292
+ try:
293
+ params["limit"] = int(params["limit"])
294
+ except ValueError:
295
+ pass
296
+
297
+ # Handle fields parameter
298
+ if "fields" in params:
299
+ fields_str = params.pop("fields")
300
+ fields = fields_str.split(",")
301
+ params["fields"] = fields
302
+
303
+ # Handle sortby parameter
304
+ if "sortby" in params:
305
+ sortby_str = params.pop("sortby")
306
+ sortby = sortby_str.split(",")
307
+ params["sortby"] = sortby
308
+
309
+ # Handle filter parameter mapping (fixed for collections-search)
310
+ if "filter" in params:
311
+ params["filter_expr"] = params.pop("filter")
312
+
313
+ # Handle filter-lang parameter mapping (fixed for collections-search)
314
+ if "filter-lang" in params:
315
+ params["filter_lang"] = params.pop("filter-lang")
316
+
317
+ collections = await self.client.all_collections(request=request, **params)
318
+ return collections
319
+
320
+ async def collections_search_post_endpoint(
321
+ self, request: Request, body: dict
322
+ ) -> Union[Collections, Response]:
323
+ """POST /collections-search endpoint.
324
+
325
+ Args:
326
+ request: Request object.
327
+ body: Search request body.
328
+
329
+ Returns:
330
+ Collections: Collections object.
331
+ """
332
+ # Convert the dict to an ExtendedSearch model
333
+ search_request = CollectionsSearchRequest.model_validate(body)
334
+
335
+ # Check if fields are present in the body
336
+ if "fields" in body:
337
+ # Extract fields from body and add them to search_request
338
+ if hasattr(search_request, "field"):
339
+ from stac_pydantic.api.extensions.fields import FieldsExtension
340
+
341
+ fields_data = body["fields"]
342
+ search_request.field = FieldsExtension(
343
+ includes=fields_data.get("include"),
344
+ excludes=fields_data.get("exclude"),
345
+ )
346
+
347
+ # Set the postbody on the request for pagination links
348
+ request.postbody = body
349
+
350
+ collections = await self.client.post_all_collections(
351
+ search_request=search_request, request=request
352
+ )
353
+
354
+ return collections
355
+
356
+ @classmethod
357
+ def from_extensions(
358
+ cls, extensions: List[ApiExtension]
359
+ ) -> "CollectionsSearchEndpointExtension":
360
+ """Create a CollectionsSearchEndpointExtension from a list of extensions.
361
+
362
+ Args:
363
+ extensions: List of extensions to include in the CollectionsSearchEndpointExtension.
364
+
365
+ Returns:
366
+ CollectionsSearchEndpointExtension: A new CollectionsSearchEndpointExtension instance.
367
+ """
368
+ from stac_fastapi.api.models import (
369
+ create_get_request_model,
370
+ create_post_request_model,
371
+ )
372
+
373
+ get_model = create_get_request_model(extensions)
374
+ post_model = create_post_request_model(extensions)
375
+
376
+ return cls(
377
+ GET=get_model,
378
+ POST=post_model,
379
+ conformance_classes=[
380
+ ext.conformance_classes
381
+ for ext in extensions
382
+ if hasattr(ext, "conformance_classes")
383
+ ],
384
+ )
@@ -0,0 +1,301 @@
1
+ """Utilities for connecting to and managing Redis connections."""
2
+
3
+ import json
4
+ import logging
5
+ from typing import List, Optional, Tuple
6
+ from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
7
+
8
+ from pydantic import field_validator
9
+ from pydantic_settings import BaseSettings
10
+ from redis import asyncio as aioredis
11
+ from redis.asyncio.sentinel import Sentinel
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class RedisSentinelSettings(BaseSettings):
17
+ """Configuration for connecting to Redis Sentinel."""
18
+
19
+ REDIS_SENTINEL_HOSTS: str = ""
20
+ REDIS_SENTINEL_PORTS: str = "26379"
21
+ REDIS_SENTINEL_MASTER_NAME: str = "master"
22
+ REDIS_DB: int = 15
23
+
24
+ REDIS_MAX_CONNECTIONS: int = 10
25
+ REDIS_RETRY_TIMEOUT: bool = True
26
+ REDIS_DECODE_RESPONSES: bool = True
27
+ REDIS_CLIENT_NAME: str = "stac-fastapi-app"
28
+ REDIS_HEALTH_CHECK_INTERVAL: int = 30
29
+ REDIS_SELF_LINK_TTL: int = 1800
30
+
31
+ @field_validator("REDIS_DB")
32
+ @classmethod
33
+ def validate_db_sentinel(cls, v: int) -> int:
34
+ """Validate REDIS_DB is not negative integer."""
35
+ if v < 0:
36
+ raise ValueError("REDIS_DB must be a positive integer")
37
+ return v
38
+
39
+ @field_validator("REDIS_MAX_CONNECTIONS")
40
+ @classmethod
41
+ def validate_max_connections_sentinel(cls, v: int) -> int:
42
+ """Validate REDIS_MAX_CONNECTIONS is at least 1."""
43
+ if v < 1:
44
+ raise ValueError("REDIS_MAX_CONNECTIONS must be at least 1")
45
+ return v
46
+
47
+ @field_validator("REDIS_HEALTH_CHECK_INTERVAL")
48
+ @classmethod
49
+ def validate_health_check_interval_sentinel(cls, v: int) -> int:
50
+ """Validate REDIS_HEALTH_CHECK_INTERVAL is not negative integer."""
51
+ if v < 0:
52
+ raise ValueError("REDIS_HEALTH_CHECK_INTERVAL must be a positive integer")
53
+ return v
54
+
55
+ @field_validator("REDIS_SELF_LINK_TTL")
56
+ @classmethod
57
+ def validate_self_link_ttl_sentinel(cls, v: int) -> int:
58
+ """Validate REDIS_SELF_LINK_TTL is not a negative integer."""
59
+ if v < 0:
60
+ raise ValueError("REDIS_SELF_LINK_TTL must be a positive integer")
61
+ return v
62
+
63
+ def get_sentinel_hosts(self) -> List[str]:
64
+ """Parse Redis Sentinel hosts from string to list."""
65
+ if not self.REDIS_SENTINEL_HOSTS:
66
+ return []
67
+
68
+ if self.REDIS_SENTINEL_HOSTS.strip().startswith("["):
69
+ return json.loads(self.REDIS_SENTINEL_HOSTS)
70
+ else:
71
+ return [
72
+ h.strip() for h in self.REDIS_SENTINEL_HOSTS.split(",") if h.strip()
73
+ ]
74
+
75
+ def get_sentinel_ports(self) -> List[int]:
76
+ """Parse Redis Sentinel ports from string to list of integers."""
77
+ if not self.REDIS_SENTINEL_PORTS:
78
+ return [26379]
79
+
80
+ if self.REDIS_SENTINEL_PORTS.strip().startswith("["):
81
+ return json.loads(self.REDIS_SENTINEL_PORTS)
82
+ else:
83
+ ports_str_list = [
84
+ p.strip() for p in self.REDIS_SENTINEL_PORTS.split(",") if p.strip()
85
+ ]
86
+ return [int(port) for port in ports_str_list]
87
+
88
+ def get_sentinel_nodes(self) -> List[Tuple[str, int]]:
89
+ """Get list of (host, port) tuples for Sentinel connection."""
90
+ hosts = self.get_sentinel_hosts()
91
+ ports = self.get_sentinel_ports()
92
+
93
+ if not hosts:
94
+ return []
95
+
96
+ if len(ports) == 1 and len(hosts) > 1:
97
+ ports = ports * len(hosts)
98
+
99
+ if len(hosts) != len(ports):
100
+ raise ValueError(
101
+ f"Mismatch between hosts ({len(hosts)}) and ports ({len(ports)})"
102
+ )
103
+
104
+ return [(str(host), int(port)) for host, port in zip(hosts, ports)]
105
+
106
+
107
+ class RedisSettings(BaseSettings):
108
+ """Configuration for connecting Redis."""
109
+
110
+ REDIS_HOST: str = ""
111
+ REDIS_PORT: int = 6379
112
+ REDIS_DB: int = 15
113
+
114
+ REDIS_MAX_CONNECTIONS: int = 10
115
+ REDIS_RETRY_TIMEOUT: bool = True
116
+ REDIS_DECODE_RESPONSES: bool = True
117
+ REDIS_CLIENT_NAME: str = "stac-fastapi-app"
118
+ REDIS_HEALTH_CHECK_INTERVAL: int = 30
119
+ REDIS_SELF_LINK_TTL: int = 1800
120
+
121
+ @field_validator("REDIS_PORT")
122
+ @classmethod
123
+ def validate_port_standalone(cls, v: int) -> int:
124
+ """Validate REDIS_PORT is not a negative integer."""
125
+ if v < 0:
126
+ raise ValueError("REDIS_PORT must be a positive integer")
127
+ return v
128
+
129
+ @field_validator("REDIS_DB")
130
+ @classmethod
131
+ def validate_db_standalone(cls, v: int) -> int:
132
+ """Validate REDIS_DB is not a negative integer."""
133
+ if v < 0:
134
+ raise ValueError("REDIS_DB must be a positive integer")
135
+ return v
136
+
137
+ @field_validator("REDIS_MAX_CONNECTIONS")
138
+ @classmethod
139
+ def validate_max_connections_standalone(cls, v: int) -> int:
140
+ """Validate REDIS_MAX_CONNECTIONS is at least 1."""
141
+ if v < 1:
142
+ raise ValueError("REDIS_MAX_CONNECTIONS must be at least 1")
143
+ return v
144
+
145
+ @field_validator("REDIS_HEALTH_CHECK_INTERVAL")
146
+ @classmethod
147
+ def validate_health_check_interval_standalone(cls, v: int) -> int:
148
+ """Validate REDIS_HEALTH_CHECK_INTERVAL is not a negative."""
149
+ if v < 0:
150
+ raise ValueError("REDIS_HEALTH_CHECK_INTERVAL must be a positive integer")
151
+ return v
152
+
153
+ @field_validator("REDIS_SELF_LINK_TTL")
154
+ @classmethod
155
+ def validate_self_link_ttl_standalone(cls, v: int) -> int:
156
+ """Validate REDIS_SELF_LINK_TTL is negative."""
157
+ if v < 0:
158
+ raise ValueError("REDIS_SELF_LINK_TTL must be a positive integer")
159
+ return v
160
+
161
+
162
+ # Configure only one Redis configuration
163
+ sentinel_settings = RedisSentinelSettings()
164
+ standalone_settings = RedisSettings()
165
+
166
+
167
+ async def connect_redis() -> Optional[aioredis.Redis]:
168
+ """Return a Redis connection Redis or Redis Sentinel."""
169
+ try:
170
+ if sentinel_settings.REDIS_SENTINEL_HOSTS:
171
+ sentinel_nodes = sentinel_settings.get_sentinel_nodes()
172
+ sentinel = Sentinel(
173
+ sentinel_nodes,
174
+ decode_responses=sentinel_settings.REDIS_DECODE_RESPONSES,
175
+ )
176
+
177
+ redis = sentinel.master_for(
178
+ service_name=sentinel_settings.REDIS_SENTINEL_MASTER_NAME,
179
+ db=sentinel_settings.REDIS_DB,
180
+ decode_responses=sentinel_settings.REDIS_DECODE_RESPONSES,
181
+ retry_on_timeout=sentinel_settings.REDIS_RETRY_TIMEOUT,
182
+ client_name=sentinel_settings.REDIS_CLIENT_NAME,
183
+ max_connections=sentinel_settings.REDIS_MAX_CONNECTIONS,
184
+ health_check_interval=sentinel_settings.REDIS_HEALTH_CHECK_INTERVAL,
185
+ )
186
+ logger.info("Connected to Redis Sentinel")
187
+
188
+ elif standalone_settings.REDIS_HOST:
189
+ pool = aioredis.ConnectionPool(
190
+ host=standalone_settings.REDIS_HOST,
191
+ port=standalone_settings.REDIS_PORT,
192
+ db=standalone_settings.REDIS_DB,
193
+ max_connections=standalone_settings.REDIS_MAX_CONNECTIONS,
194
+ decode_responses=standalone_settings.REDIS_DECODE_RESPONSES,
195
+ retry_on_timeout=standalone_settings.REDIS_RETRY_TIMEOUT,
196
+ health_check_interval=standalone_settings.REDIS_HEALTH_CHECK_INTERVAL,
197
+ )
198
+ redis = aioredis.Redis(
199
+ connection_pool=pool, client_name=standalone_settings.REDIS_CLIENT_NAME
200
+ )
201
+ logger.info("Connected to Redis")
202
+ else:
203
+ logger.warning("No Redis configuration found")
204
+ return None
205
+
206
+ return redis
207
+
208
+ except aioredis.ConnectionError as e:
209
+ logger.error(f"Redis connection error: {e}")
210
+ return None
211
+ except aioredis.AuthenticationError as e:
212
+ logger.error(f"Redis authentication error: {e}")
213
+ return None
214
+ except aioredis.TimeoutError as e:
215
+ logger.error(f"Redis timeout error: {e}")
216
+ return None
217
+ except Exception as e:
218
+ logger.error(f"Failed to connect to Redis: {e}")
219
+ return None
220
+
221
+
222
+ def get_redis_key(url: str, token: str) -> str:
223
+ """Create Redis key using URL path and token."""
224
+ parsed = urlparse(url)
225
+ return f"nav:{parsed.path}:{token}"
226
+
227
+
228
+ def build_url_with_token(base_url: str, token: str) -> str:
229
+ """Build URL with token parameter."""
230
+ parsed = urlparse(base_url)
231
+ query_params = parse_qs(parsed.query)
232
+
233
+ query_params["token"] = [token]
234
+
235
+ new_query = urlencode(query_params, doseq=True)
236
+
237
+ return urlunparse(
238
+ (
239
+ parsed.scheme,
240
+ parsed.netloc,
241
+ parsed.path,
242
+ parsed.params,
243
+ new_query,
244
+ parsed.fragment,
245
+ )
246
+ )
247
+
248
+
249
+ async def save_prev_link(
250
+ redis: aioredis.Redis, next_url: str, current_url: str, next_token: str
251
+ ) -> None:
252
+ """Save the current page as the previous link for the next URL."""
253
+ if next_url and next_token:
254
+ if sentinel_settings.REDIS_SENTINEL_HOSTS:
255
+ ttl_seconds = sentinel_settings.REDIS_SELF_LINK_TTL
256
+ elif standalone_settings.REDIS_HOST:
257
+ ttl_seconds = standalone_settings.REDIS_SELF_LINK_TTL
258
+ key = get_redis_key(next_url, next_token)
259
+ await redis.setex(key, ttl_seconds, current_url)
260
+
261
+
262
+ async def get_prev_link(
263
+ redis: aioredis.Redis, current_url: str, current_token: str
264
+ ) -> Optional[str]:
265
+ """Get the previous page link for the current token."""
266
+ if not current_url or not current_token:
267
+ return None
268
+ key = get_redis_key(current_url, current_token)
269
+ return await redis.get(key)
270
+
271
+
272
+ async def redis_pagination_links(
273
+ current_url: str, token: str, next_token: str, links: list
274
+ ) -> None:
275
+ """Handle Redis pagination."""
276
+ redis = await connect_redis()
277
+ if not redis:
278
+ logger.warning("Redis connection failed.")
279
+ return
280
+
281
+ try:
282
+ if next_token:
283
+ next_url = build_url_with_token(current_url, next_token)
284
+ await save_prev_link(redis, next_url, current_url, next_token)
285
+
286
+ if token:
287
+ prev_link = await get_prev_link(redis, current_url, token)
288
+ if prev_link:
289
+ links.insert(
290
+ 0,
291
+ {
292
+ "rel": "previous",
293
+ "type": "application/json",
294
+ "method": "GET",
295
+ "href": prev_link,
296
+ },
297
+ )
298
+ except Exception as e:
299
+ logger.warning(f"Redis pagination operation failed: {e}")
300
+ finally:
301
+ await redis.close()
@@ -1,2 +1,2 @@
1
1
  """library version."""
2
- __version__ = "6.6.0"
2
+ __version__ = "6.7.0"
@@ -1,20 +0,0 @@
1
- # stac-fastapi-core
2
-
3
- Core functionality for stac-fastapi. For full documentation, please see the [main README](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/blob/main/README.md).
4
-
5
- ## Package Information
6
-
7
- - **Package name**: stac-fastapi-core
8
- - **Description**: Core functionality for STAC API implementations.
9
- - **Documentation**: [https://stac-utils.github.io/stac-fastapi-elasticsearch-opensearch/](https://stac-utils.github.io/stac-fastapi-elasticsearch-opensearch/)
10
- - **Source**: [GitHub Repository](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/)
11
-
12
- ## Installation
13
-
14
- ```bash
15
- pip install stac-fastapi-core
16
- ```
17
-
18
- ## Quick Start
19
-
20
- For detailed usage and examples, please refer to the [main documentation](https://stac-utils.github.io/stac-fastapi-elasticsearch-opensearch/).
@@ -1,194 +0,0 @@
1
- """Collections search extension."""
2
-
3
- from typing import List, Optional, Type, Union
4
-
5
- from fastapi import APIRouter, FastAPI, Request
6
- from fastapi.responses import JSONResponse
7
- from pydantic import BaseModel
8
- from stac_pydantic.api.search import ExtendedSearch
9
- from starlette.responses import Response
10
-
11
- from stac_fastapi.api.models import APIRequest
12
- from stac_fastapi.types.core import BaseCoreClient
13
- from stac_fastapi.types.extension import ApiExtension
14
- from stac_fastapi.types.stac import Collections
15
-
16
-
17
- class CollectionsSearchRequest(ExtendedSearch):
18
- """Extended search model for collections with free text search support."""
19
-
20
- q: Optional[Union[str, List[str]]] = None
21
- token: Optional[str] = None
22
- query: Optional[
23
- str
24
- ] = None # Legacy query extension (deprecated but still supported)
25
-
26
-
27
- class CollectionsSearchEndpointExtension(ApiExtension):
28
- """Collections search endpoint extension.
29
-
30
- This extension adds a dedicated /collections-search endpoint for collection search operations.
31
- """
32
-
33
- def __init__(
34
- self,
35
- client: Optional[BaseCoreClient] = None,
36
- settings: dict = None,
37
- GET: Optional[Type[Union[BaseModel, APIRequest]]] = None,
38
- POST: Optional[Type[Union[BaseModel, APIRequest]]] = None,
39
- conformance_classes: Optional[List[str]] = None,
40
- ):
41
- """Initialize the extension.
42
-
43
- Args:
44
- client: Optional BaseCoreClient instance to use for this extension.
45
- settings: Dictionary of settings to pass to the extension.
46
- GET: Optional GET request model.
47
- POST: Optional POST request model.
48
- conformance_classes: Optional list of conformance classes to add to the API.
49
- """
50
- super().__init__()
51
- self.client = client
52
- self.settings = settings or {}
53
- self.GET = GET
54
- self.POST = POST
55
- self.conformance_classes = conformance_classes or []
56
- self.router = APIRouter()
57
- self.create_endpoints()
58
-
59
- def register(self, app: FastAPI) -> None:
60
- """Register the extension with a FastAPI application.
61
-
62
- Args:
63
- app: target FastAPI application.
64
-
65
- Returns:
66
- None
67
- """
68
- app.include_router(self.router)
69
-
70
- def create_endpoints(self) -> None:
71
- """Create endpoints for the extension."""
72
- if self.GET:
73
- self.router.add_api_route(
74
- name="Get Collections Search",
75
- path="/collections-search",
76
- response_model=None,
77
- response_class=JSONResponse,
78
- methods=["GET"],
79
- endpoint=self.collections_search_get_endpoint,
80
- **(self.settings if isinstance(self.settings, dict) else {}),
81
- )
82
-
83
- if self.POST:
84
- self.router.add_api_route(
85
- name="Post Collections Search",
86
- path="/collections-search",
87
- response_model=None,
88
- response_class=JSONResponse,
89
- methods=["POST"],
90
- endpoint=self.collections_search_post_endpoint,
91
- **(self.settings if isinstance(self.settings, dict) else {}),
92
- )
93
-
94
- async def collections_search_get_endpoint(
95
- self, request: Request
96
- ) -> Union[Collections, Response]:
97
- """GET /collections-search endpoint.
98
-
99
- Args:
100
- request: Request object.
101
-
102
- Returns:
103
- Collections: Collections object.
104
- """
105
- # Extract query parameters from the request
106
- params = dict(request.query_params)
107
-
108
- # Convert query parameters to appropriate types
109
- if "limit" in params:
110
- try:
111
- params["limit"] = int(params["limit"])
112
- except ValueError:
113
- pass
114
-
115
- # Handle fields parameter
116
- if "fields" in params:
117
- fields_str = params.pop("fields")
118
- fields = fields_str.split(",")
119
- params["fields"] = fields
120
-
121
- # Handle sortby parameter
122
- if "sortby" in params:
123
- sortby_str = params.pop("sortby")
124
- sortby = sortby_str.split(",")
125
- params["sortby"] = sortby
126
-
127
- collections = await self.client.all_collections(request=request, **params)
128
- return collections
129
-
130
- async def collections_search_post_endpoint(
131
- self, request: Request, body: dict
132
- ) -> Union[Collections, Response]:
133
- """POST /collections-search endpoint.
134
-
135
- Args:
136
- request: Request object.
137
- body: Search request body.
138
-
139
- Returns:
140
- Collections: Collections object.
141
- """
142
- # Convert the dict to an ExtendedSearch model
143
- search_request = CollectionsSearchRequest.model_validate(body)
144
-
145
- # Check if fields are present in the body
146
- if "fields" in body:
147
- # Extract fields from body and add them to search_request
148
- if hasattr(search_request, "field"):
149
- from stac_pydantic.api.extensions.fields import FieldsExtension
150
-
151
- fields_data = body["fields"]
152
- search_request.field = FieldsExtension(
153
- includes=fields_data.get("include"),
154
- excludes=fields_data.get("exclude"),
155
- )
156
-
157
- # Set the postbody on the request for pagination links
158
- request.postbody = body
159
-
160
- collections = await self.client.post_all_collections(
161
- search_request=search_request, request=request
162
- )
163
-
164
- return collections
165
-
166
- @classmethod
167
- def from_extensions(
168
- cls, extensions: List[ApiExtension]
169
- ) -> "CollectionsSearchEndpointExtension":
170
- """Create a CollectionsSearchEndpointExtension from a list of extensions.
171
-
172
- Args:
173
- extensions: List of extensions to include in the CollectionsSearchEndpointExtension.
174
-
175
- Returns:
176
- CollectionsSearchEndpointExtension: A new CollectionsSearchEndpointExtension instance.
177
- """
178
- from stac_fastapi.api.models import (
179
- create_get_request_model,
180
- create_post_request_model,
181
- )
182
-
183
- get_model = create_get_request_model(extensions)
184
- post_model = create_post_request_model(extensions)
185
-
186
- return cls(
187
- GET=get_model,
188
- POST=post_model,
189
- conformance_classes=[
190
- ext.conformance_classes
191
- for ext in extensions
192
- if hasattr(ext, "conformance_classes")
193
- ],
194
- )