stac-fastapi-core 6.5.1__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.
@@ -3,6 +3,8 @@
3
3
  import abc
4
4
  from typing import Any, Dict, Iterable, List, Optional, Tuple
5
5
 
6
+ from stac_pydantic.shared import BBox
7
+
6
8
 
7
9
  class BaseDatabaseLogic(abc.ABC):
8
10
  """
@@ -19,7 +21,12 @@ class BaseDatabaseLogic(abc.ABC):
19
21
  limit: int,
20
22
  request: Any = None,
21
23
  sort: Optional[List[Dict[str, Any]]] = None,
22
- ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
24
+ bbox: Optional[BBox] = None,
25
+ q: Optional[List[str]] = None,
26
+ filter: Optional[Dict[str, Any]] = None,
27
+ query: Optional[Dict[str, Dict[str, Any]]] = None,
28
+ datetime: Optional[str] = None,
29
+ ) -> Tuple[List[Dict[str, Any]], Optional[str], Optional[int]]:
23
30
  """Retrieve a list of collections from the database, supporting pagination.
24
31
 
25
32
  Args:
@@ -27,9 +34,14 @@ class BaseDatabaseLogic(abc.ABC):
27
34
  limit (int): The number of results to return.
28
35
  request (Any, optional): The FastAPI request object. Defaults to None.
29
36
  sort (Optional[List[Dict[str, Any]]], optional): Optional sort parameter. Defaults to None.
37
+ bbox (Optional[BBox], optional): Bounding box to filter collections by spatial extent. Defaults to None.
38
+ q (Optional[List[str]], optional): Free text search terms. Defaults to None.
39
+ filter (Optional[Dict[str, Any]], optional): Structured query in CQL2 format. Defaults to None.
40
+ query (Optional[Dict[str, Dict[str, Any]]], optional): Query extension parameters. Defaults to None.
41
+ datetime (Optional[str], optional): Temporal filter. Defaults to None.
30
42
 
31
43
  Returns:
32
- A tuple of (collections, next pagination token if any).
44
+ A tuple of (collections, next pagination token if any, optional count).
33
45
  """
34
46
  pass
35
47
 
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,
@@ -201,17 +202,6 @@ class CoreClient(AsyncBaseCoreClient):
201
202
  ]
202
203
  )
203
204
 
204
- collections = await self.all_collections(request=kwargs["request"])
205
- for collection in collections["collections"]:
206
- landing_page["links"].append(
207
- {
208
- "rel": Relations.child.value,
209
- "type": MimeTypes.json.value,
210
- "title": collection.get("title") or collection.get("id"),
211
- "href": urljoin(base_url, f"collections/{collection['id']}"),
212
- }
213
- )
214
-
215
205
  # Add OpenAPI URL
216
206
  landing_page["links"].append(
217
207
  {
@@ -241,6 +231,7 @@ class CoreClient(AsyncBaseCoreClient):
241
231
  async def all_collections(
242
232
  self,
243
233
  limit: Optional[int] = None,
234
+ bbox: Optional[BBox] = None,
244
235
  datetime: Optional[str] = None,
245
236
  fields: Optional[List[str]] = None,
246
237
  sortby: Optional[Union[str, List[str]]] = None,
@@ -255,49 +246,50 @@ class CoreClient(AsyncBaseCoreClient):
255
246
  """Read all collections from the database.
256
247
 
257
248
  Args:
258
- datetime (Optional[str]): Filter collections by datetime range.
259
249
  limit (Optional[int]): Maximum number of collections to return.
250
+ bbox (Optional[BBox]): Bounding box to filter collections by spatial extent.
251
+ datetime (Optional[str]): Filter collections by datetime range.
260
252
  fields (Optional[List[str]]): Fields to include or exclude from the results.
261
- sortby (Optional[str]): Sorting options for the results.
253
+ sortby (Optional[Union[str, List[str]]]): Sorting options for the results.
262
254
  filter_expr (Optional[str]): Structured filter expression in CQL2 JSON or CQL2-text format.
263
- query (Optional[str]): Legacy query parameter (deprecated).
264
255
  filter_lang (Optional[str]): Must be 'cql2-json' or 'cql2-text' if specified, other values will result in an error.
265
256
  q (Optional[Union[str, List[str]]]): Free text search terms.
257
+ query (Optional[str]): Legacy query parameter (deprecated).
258
+ request (Request): FastAPI Request object.
259
+ token (Optional[str]): Pagination token for retrieving the next page of results.
266
260
  **kwargs: Keyword arguments from the request.
267
261
 
268
262
  Returns:
269
263
  A Collections object containing all the collections in the database and links to various resources.
270
264
  """
271
265
  base_url = str(request.base_url)
266
+ redis_enable = get_bool_env("REDIS_ENABLE", default=False)
272
267
 
273
- # Get the global limit from environment variable
274
- global_limit = None
275
- env_limit = os.getenv("STAC_ITEM_LIMIT")
276
- if env_limit:
277
- try:
278
- global_limit = int(env_limit)
279
- except ValueError:
280
- # Handle invalid integer in environment variable
281
- pass
282
-
283
- # Apply global limit if it exists
284
- if global_limit is not None:
285
- # If a limit was provided, use the smaller of the two
286
- if limit is not None:
287
- limit = min(limit, global_limit)
288
- else:
289
- limit = global_limit
268
+ global_max_limit = (
269
+ int(os.getenv("STAC_GLOBAL_COLLECTION_MAX_LIMIT"))
270
+ if os.getenv("STAC_GLOBAL_COLLECTION_MAX_LIMIT")
271
+ else None
272
+ )
273
+ query_limit = request.query_params.get("limit")
274
+ default_limit = int(os.getenv("STAC_DEFAULT_COLLECTION_LIMIT", 300))
275
+
276
+ body_limit = None
277
+ try:
278
+ if request.method == "POST" and request.body():
279
+ body_data = await request.json()
280
+ body_limit = body_data.get("limit")
281
+ except Exception:
282
+ pass
283
+
284
+ if body_limit is not None:
285
+ limit = int(body_limit)
286
+ elif query_limit:
287
+ limit = int(query_limit)
290
288
  else:
291
- # No global limit, use provided limit or default
292
- if limit is None:
293
- query_limit = request.query_params.get("limit")
294
- if query_limit:
295
- try:
296
- limit = int(query_limit)
297
- except ValueError:
298
- limit = 10
299
- else:
300
- limit = 10
289
+ limit = default_limit
290
+
291
+ if global_max_limit is not None:
292
+ limit = min(limit, global_max_limit)
301
293
 
302
294
  # Get token from query params only if not already provided (for GET requests)
303
295
  if token is None:
@@ -401,6 +393,7 @@ class CoreClient(AsyncBaseCoreClient):
401
393
  limit=limit,
402
394
  request=request,
403
395
  sort=sort,
396
+ bbox=bbox,
404
397
  q=q_list,
405
398
  filter=parsed_filter,
406
399
  query=parsed_query,
@@ -426,6 +419,14 @@ class CoreClient(AsyncBaseCoreClient):
426
419
  },
427
420
  ]
428
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
+
429
430
  if next_token:
430
431
  next_link = PagingLinks(next=next_token, request=request).link_next()
431
432
  links.append(next_link)
@@ -502,6 +503,7 @@ class CoreClient(AsyncBaseCoreClient):
502
503
  # Pass all parameters from search_request to all_collections
503
504
  return await self.all_collections(
504
505
  limit=search_request.limit if hasattr(search_request, "limit") else None,
506
+ bbox=search_request.bbox if hasattr(search_request, "bbox") else None,
505
507
  datetime=search_request.datetime
506
508
  if hasattr(search_request, "datetime")
507
509
  else None,
@@ -569,7 +571,7 @@ class CoreClient(AsyncBaseCoreClient):
569
571
  request (Request): FastAPI Request object.
570
572
  bbox (Optional[BBox]): Optional bounding box filter.
571
573
  datetime (Optional[str]): Optional datetime or interval filter.
572
- limit (Optional[int]): Optional page size. Defaults to env ``STAC_ITEM_LIMIT`` when unset.
574
+ limit (Optional[int]): Optional page size. Defaults to env `STAC_DEFAULT_ITEM_LIMIT` when unset.
573
575
  sortby (Optional[str]): Optional sort specification. Accepts repeated values
574
576
  like ``sortby=-properties.datetime`` or ``sortby=+id``. Bare fields (e.g. ``sortby=id``)
575
577
  imply ascending order.
@@ -660,15 +662,12 @@ class CoreClient(AsyncBaseCoreClient):
660
662
  q (Optional[List[str]]): Free text query to filter the results.
661
663
  intersects (Optional[str]): GeoJSON geometry to search in.
662
664
  kwargs: Additional parameters to be passed to the API.
663
-
664
665
  Returns:
665
666
  ItemCollection: Collection of `Item` objects representing the search results.
666
667
 
667
668
  Raises:
668
669
  HTTPException: If any error occurs while searching the catalog.
669
670
  """
670
- limit = int(request.query_params.get("limit", os.getenv("STAC_ITEM_LIMIT", 10)))
671
-
672
671
  base_args = {
673
672
  "collections": collections,
674
673
  "ids": ids,
@@ -743,9 +742,37 @@ class CoreClient(AsyncBaseCoreClient):
743
742
  Raises:
744
743
  HTTPException: If there is an error with the cql2_json filter.
745
744
  """
746
- base_url = str(request.base_url)
745
+ global_max_limit = (
746
+ int(os.getenv("STAC_GLOBAL_ITEM_MAX_LIMIT"))
747
+ if os.getenv("STAC_GLOBAL_ITEM_MAX_LIMIT")
748
+ else None
749
+ )
750
+ query_limit = request.query_params.get("limit")
751
+ default_limit = int(os.getenv("STAC_DEFAULT_ITEM_LIMIT", 10))
747
752
 
753
+ body_limit = None
754
+ try:
755
+ if request.method == "POST" and request.body():
756
+ body_data = await request.json()
757
+ body_limit = body_data.get("limit")
758
+ except Exception:
759
+ pass
760
+
761
+ if body_limit is not None:
762
+ limit = int(body_limit)
763
+ elif query_limit:
764
+ limit = int(query_limit)
765
+ else:
766
+ limit = default_limit
767
+
768
+ if global_max_limit:
769
+ limit = min(limit, global_max_limit)
770
+
771
+ search_request.limit = limit
772
+
773
+ base_url = str(request.base_url)
748
774
  search = self.database.make_search()
775
+ redis_enable = get_bool_env("REDIS_ENABLE", default=False)
749
776
 
750
777
  if search_request.ids:
751
778
  search = self.database.apply_ids_filter(
@@ -819,7 +846,6 @@ class CoreClient(AsyncBaseCoreClient):
819
846
  if hasattr(search_request, "sortby") and getattr(search_request, "sortby"):
820
847
  sort = self.database.populate_sort(getattr(search_request, "sortby"))
821
848
 
822
- limit = 10
823
849
  if search_request.limit:
824
850
  limit = search_request.limit
825
851
 
@@ -850,6 +876,34 @@ class CoreClient(AsyncBaseCoreClient):
850
876
  ]
851
877
  links = await PagingLinks(request=request, next=next_token).get_links()
852
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
+
853
907
  return stac_types.ItemCollection(
854
908
  type="FeatureCollection",
855
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