stac-fastapi-core 6.6.0__py3-none-any.whl → 6.7.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.
stac_fastapi/core/core.py CHANGED
@@ -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,
@@ -1,8 +1,8 @@
1
1
  """Collections search extension."""
2
2
 
3
- from typing import List, Optional, Type, Union
3
+ from typing import Any, Dict, List, Optional, Type, Union
4
4
 
5
- from fastapi import APIRouter, FastAPI, Request
5
+ from fastapi import APIRouter, Body, FastAPI, Query, Request
6
6
  from fastapi.responses import JSONResponse
7
7
  from pydantic import BaseModel
8
8
  from stac_pydantic.api.search import ExtendedSearch
@@ -22,6 +22,168 @@ class CollectionsSearchRequest(ExtendedSearch):
22
22
  query: Optional[
23
23
  str
24
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
25
187
 
26
188
 
27
189
  class CollectionsSearchEndpointExtension(ApiExtension):
@@ -54,7 +216,6 @@ class CollectionsSearchEndpointExtension(ApiExtension):
54
216
  self.POST = POST
55
217
  self.conformance_classes = conformance_classes or []
56
218
  self.router = APIRouter()
57
- self.create_endpoints()
58
219
 
59
220
  def register(self, app: FastAPI) -> None:
60
221
  """Register the extension with a FastAPI application.
@@ -65,32 +226,53 @@ class CollectionsSearchEndpointExtension(ApiExtension):
65
226
  Returns:
66
227
  None
67
228
  """
68
- app.include_router(self.router)
229
+ # Remove any existing routes to avoid duplicates
230
+ self.router.routes = []
69
231
 
70
- def create_endpoints(self) -> None:
71
- """Create endpoints for the extension."""
232
+ # Recreate endpoints with proper OpenAPI documentation
72
233
  if self.GET:
234
+ original_endpoint = self.collections_search_get_endpoint
235
+ documented_endpoint = build_get_collections_search_doc(original_endpoint)
236
+
73
237
  self.router.add_api_route(
74
- name="Get Collections Search",
75
238
  path="/collections-search",
239
+ endpoint=documented_endpoint,
76
240
  response_model=None,
77
241
  response_class=JSONResponse,
78
242
  methods=["GET"],
79
- endpoint=self.collections_search_get_endpoint,
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"],
80
250
  **(self.settings if isinstance(self.settings, dict) else {}),
81
251
  )
82
252
 
83
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
+
84
259
  self.router.add_api_route(
85
- name="Post Collections Search",
86
260
  path="/collections-search",
261
+ endpoint=documented_post_endpoint,
87
262
  response_model=None,
88
263
  response_class=JSONResponse,
89
264
  methods=["POST"],
90
- endpoint=self.collections_search_post_endpoint,
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"],
91
271
  **(self.settings if isinstance(self.settings, dict) else {}),
92
272
  )
93
273
 
274
+ app.include_router(self.router)
275
+
94
276
  async def collections_search_get_endpoint(
95
277
  self, request: Request
96
278
  ) -> Union[Collections, Response]:
@@ -124,6 +306,14 @@ class CollectionsSearchEndpointExtension(ApiExtension):
124
306
  sortby = sortby_str.split(",")
125
307
  params["sortby"] = sortby
126
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
+
127
317
  collections = await self.client.all_collections(request=request, **params)
128
318
  return collections
129
319
 
@@ -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,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
@@ -2,23 +2,24 @@ stac_fastapi/core/__init__.py,sha256=8izV3IWRGdXmDOK1hIPQAanbWs9EI04PJCGgqG1ZGIs
2
2
  stac_fastapi/core/base_database_logic.py,sha256=3_XJ_j06ogQHE-Tcjkv5Vye_zNDn9OEU9lNYU03am1k,4618
3
3
  stac_fastapi/core/base_settings.py,sha256=R3_Sx7n5XpGMs3zAwFJD7y008WvGU_uI2xkaabm82Kg,239
4
4
  stac_fastapi/core/basic_auth.py,sha256=RhFv3RVSHF6OaqnaaU2DO4ncJ_S5nB1q8UNpnVJJsrk,2155
5
- stac_fastapi/core/core.py,sha256=f1-TJSH7Hl5MYTtdgjBUGUvFi7gFLex3MxU4ubYBqhI,48838
5
+ stac_fastapi/core/core.py,sha256=0_H0QKQN-fkvzAidqGt8MvYfO2f_OF82pXXYs9HpQ_4,50387
6
6
  stac_fastapi/core/datetime_utils.py,sha256=TrTgbU7AKNC-ic4a3HptfE5XAc9tHR7uJasZyhOuwnc,2633
7
7
  stac_fastapi/core/rate_limit.py,sha256=Gu8dAaJReGsj1L91U6m2tflU6RahpXDRs2-AYSKoybA,1318
8
+ stac_fastapi/core/redis_utils.py,sha256=hoghKhq7eHkqttU6T_Yq6uSWPPfgFjz9HrN8nMLJqNo,10440
8
9
  stac_fastapi/core/route_dependencies.py,sha256=hdtuMkv-zY1vg0YxiCz1aKP0SbBcORqDGEKDGgEazW8,5482
9
10
  stac_fastapi/core/serializers.py,sha256=ZW5hPgq-mftk6zxJeZGur-1Qxn7YGc3fJYFLsd-SYwM,7619
10
11
  stac_fastapi/core/session.py,sha256=aXqu4LXfVbAAsChMVXd9gAhczA2bZPne6HqPeklAwMY,474
11
12
  stac_fastapi/core/utilities.py,sha256=xXWO5oJCNDi7_C5jPYlHZD0B-DL-FN66eEUBUSW-cXw,7296
12
- stac_fastapi/core/version.py,sha256=zPQp-GtLh4R45hT8V_KAWzwZcP4-jyw7Xjms_eNMZBc,45
13
+ stac_fastapi/core/version.py,sha256=GMc7YzxyWeUVpr_RMVlweeoH2lCLxOWemF-FOkqKXx8,45
13
14
  stac_fastapi/core/extensions/__init__.py,sha256=zSIAqou8jnakWPbkh4Ddcx1-oazZVBOs7U2PAakAdU0,291
14
15
  stac_fastapi/core/extensions/aggregation.py,sha256=v1hUHqlYuMqfQ554g3cTp16pUyRYucQxPERbHPAFtf8,1878
15
- stac_fastapi/core/extensions/collections_search.py,sha256=q7eRBykEqNRCiTfkmM_TobqKkxA3n1zQ7dYo37juE6s,6503
16
+ stac_fastapi/core/extensions/collections_search.py,sha256=xpv51nffMq5a8grNSaLbv2IzeI5JH_pqcoWRbWhzn6Y,14406
16
17
  stac_fastapi/core/extensions/fields.py,sha256=NCT5XHvfaf297eDPNaIFsIzvJnbbUTpScqF0otdx0NA,1066
17
18
  stac_fastapi/core/extensions/filter.py,sha256=-NQGME7rR_ereuDx-LAa1M5JhEXFaKiTtkH2asraYHE,2998
18
19
  stac_fastapi/core/extensions/query.py,sha256=Xmo8pfZEZKPudZEjjozv3R0wLOP0ayjC9E67sBOXqWY,1803
19
20
  stac_fastapi/core/models/__init__.py,sha256=g-D1DiGfmC9Bg27DW9JzkN6fAvscv75wyhyiZ6NzvIk,48
20
21
  stac_fastapi/core/models/links.py,sha256=0dWSEMt3aa7NCISlHwo11zLBeIV1LwXG3JGjrXC3dZI,6672
21
22
  stac_fastapi/core/models/search.py,sha256=7SgAUyzHGXBXSqB4G6cwq9FMwoAS00momb7jvBkjyow,27
22
- stac_fastapi_core-6.6.0.dist-info/METADATA,sha256=h54wZw2qk4ZgGH7kFlwIFmxR3wgEHwoXJ3p69w-iOFs,2163
23
- stac_fastapi_core-6.6.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
24
- stac_fastapi_core-6.6.0.dist-info/RECORD,,
23
+ stac_fastapi_core-6.7.0.dist-info/METADATA,sha256=K8U8uKNxI9D5JM_ZIqaOV0wi0TLPgSwK9Qv4ca2VCb4,3494
24
+ stac_fastapi_core-6.7.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
25
+ stac_fastapi_core-6.7.0.dist-info/RECORD,,