stac-fastapi-core 6.6.0__tar.gz → 6.7.1__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.
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/PKG-INFO +14 -1
- stac_fastapi_core-6.7.1/README.md +32 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/pyproject.toml +1 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/core.py +40 -2
- stac_fastapi_core-6.7.1/stac_fastapi/core/extensions/collections_search.py +384 -0
- stac_fastapi_core-6.7.1/stac_fastapi/core/redis_utils.py +269 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/version.py +1 -1
- stac_fastapi_core-6.6.0/README.md +0 -20
- stac_fastapi_core-6.6.0/stac_fastapi/core/extensions/collections_search.py +0 -194
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/.gitignore +0 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/pytest.ini +0 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/__init__.py +0 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/base_database_logic.py +0 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/base_settings.py +0 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/basic_auth.py +0 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/datetime_utils.py +0 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/extensions/__init__.py +0 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/extensions/aggregation.py +0 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/extensions/fields.py +0 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/extensions/filter.py +0 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/extensions/query.py +0 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/models/__init__.py +0 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/models/links.py +0 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/models/search.py +0 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/rate_limit.py +0 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/route_dependencies.py +0 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/serializers.py +0 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/session.py +0 -0
- {stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/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.
|
|
3
|
+
Version: 6.7.1
|
|
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
|
+
[](https://pepy.tech/project/stac-fastapi-core)
|
|
42
|
+
[](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/graphs/contributors)
|
|
43
|
+
[](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/stargazers)
|
|
44
|
+
[](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/network/members)
|
|
45
|
+
[](https://pypi.org/project/stac-fastapi-elasticsearch/)
|
|
46
|
+
[](https://github.com/radiantearth/stac-spec/tree/v1.1.0)
|
|
47
|
+
[](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
|
+
[](https://pepy.tech/project/stac-fastapi-core)
|
|
8
|
+
[](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/graphs/contributors)
|
|
9
|
+
[](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/stargazers)
|
|
10
|
+
[](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/network/members)
|
|
11
|
+
[](https://pypi.org/project/stac-fastapi-elasticsearch/)
|
|
12
|
+
[](https://github.com/radiantearth/stac-spec/tree/v1.1.0)
|
|
13
|
+
[](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/).
|
|
@@ -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,269 @@
|
|
|
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, 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: Optional[int] = None
|
|
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 = Field(default=30, gt=0)
|
|
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_SELF_LINK_TTL")
|
|
40
|
+
@classmethod
|
|
41
|
+
def validate_self_link_ttl_sentinel(cls, v: int) -> int:
|
|
42
|
+
"""Validate REDIS_SELF_LINK_TTL is not a negative integer."""
|
|
43
|
+
if v < 0:
|
|
44
|
+
raise ValueError("REDIS_SELF_LINK_TTL must be a positive integer")
|
|
45
|
+
return v
|
|
46
|
+
|
|
47
|
+
def get_sentinel_hosts(self) -> List[str]:
|
|
48
|
+
"""Parse Redis Sentinel hosts from string to list."""
|
|
49
|
+
if not self.REDIS_SENTINEL_HOSTS:
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
if self.REDIS_SENTINEL_HOSTS.strip().startswith("["):
|
|
53
|
+
return json.loads(self.REDIS_SENTINEL_HOSTS)
|
|
54
|
+
else:
|
|
55
|
+
return [
|
|
56
|
+
h.strip() for h in self.REDIS_SENTINEL_HOSTS.split(",") if h.strip()
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
def get_sentinel_ports(self) -> List[int]:
|
|
60
|
+
"""Parse Redis Sentinel ports from string to list of integers."""
|
|
61
|
+
if not self.REDIS_SENTINEL_PORTS:
|
|
62
|
+
return [26379]
|
|
63
|
+
|
|
64
|
+
if self.REDIS_SENTINEL_PORTS.strip().startswith("["):
|
|
65
|
+
return json.loads(self.REDIS_SENTINEL_PORTS)
|
|
66
|
+
else:
|
|
67
|
+
ports_str_list = [
|
|
68
|
+
p.strip() for p in self.REDIS_SENTINEL_PORTS.split(",") if p.strip()
|
|
69
|
+
]
|
|
70
|
+
return [int(port) for port in ports_str_list]
|
|
71
|
+
|
|
72
|
+
def get_sentinel_nodes(self) -> List[Tuple[str, int]]:
|
|
73
|
+
"""Get list of (host, port) tuples for Sentinel connection."""
|
|
74
|
+
hosts = self.get_sentinel_hosts()
|
|
75
|
+
ports = self.get_sentinel_ports()
|
|
76
|
+
|
|
77
|
+
if not hosts:
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
if len(ports) == 1 and len(hosts) > 1:
|
|
81
|
+
ports = ports * len(hosts)
|
|
82
|
+
|
|
83
|
+
if len(hosts) != len(ports):
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"Mismatch between hosts ({len(hosts)}) and ports ({len(ports)})"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return [(str(host), int(port)) for host, port in zip(hosts, ports)]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class RedisSettings(BaseSettings):
|
|
92
|
+
"""Configuration for connecting Redis."""
|
|
93
|
+
|
|
94
|
+
REDIS_HOST: str = ""
|
|
95
|
+
REDIS_PORT: int = 6379
|
|
96
|
+
REDIS_DB: int = 15
|
|
97
|
+
|
|
98
|
+
REDIS_MAX_CONNECTIONS: Optional[int] = None
|
|
99
|
+
REDIS_RETRY_TIMEOUT: bool = True
|
|
100
|
+
REDIS_DECODE_RESPONSES: bool = True
|
|
101
|
+
REDIS_CLIENT_NAME: str = "stac-fastapi-app"
|
|
102
|
+
REDIS_HEALTH_CHECK_INTERVAL: int = Field(default=30, gt=0)
|
|
103
|
+
REDIS_SELF_LINK_TTL: int = 1800
|
|
104
|
+
|
|
105
|
+
@field_validator("REDIS_PORT")
|
|
106
|
+
@classmethod
|
|
107
|
+
def validate_port_standalone(cls, v: int) -> int:
|
|
108
|
+
"""Validate REDIS_PORT is not a negative integer."""
|
|
109
|
+
if v < 0:
|
|
110
|
+
raise ValueError("REDIS_PORT must be a positive integer")
|
|
111
|
+
return v
|
|
112
|
+
|
|
113
|
+
@field_validator("REDIS_DB")
|
|
114
|
+
@classmethod
|
|
115
|
+
def validate_db_standalone(cls, v: int) -> int:
|
|
116
|
+
"""Validate REDIS_DB is not a negative integer."""
|
|
117
|
+
if v < 0:
|
|
118
|
+
raise ValueError("REDIS_DB must be a positive integer")
|
|
119
|
+
return v
|
|
120
|
+
|
|
121
|
+
@field_validator("REDIS_SELF_LINK_TTL")
|
|
122
|
+
@classmethod
|
|
123
|
+
def validate_self_link_ttl_standalone(cls, v: int) -> int:
|
|
124
|
+
"""Validate REDIS_SELF_LINK_TTL is negative."""
|
|
125
|
+
if v < 0:
|
|
126
|
+
raise ValueError("REDIS_SELF_LINK_TTL must be a positive integer")
|
|
127
|
+
return v
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Configure only one Redis configuration
|
|
131
|
+
sentinel_settings = RedisSentinelSettings()
|
|
132
|
+
standalone_settings = RedisSettings()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def connect_redis() -> Optional[aioredis.Redis]:
|
|
136
|
+
"""Return a Redis connection Redis or Redis Sentinel."""
|
|
137
|
+
try:
|
|
138
|
+
if sentinel_settings.REDIS_SENTINEL_HOSTS:
|
|
139
|
+
sentinel_nodes = sentinel_settings.get_sentinel_nodes()
|
|
140
|
+
sentinel = Sentinel(
|
|
141
|
+
sentinel_nodes,
|
|
142
|
+
decode_responses=sentinel_settings.REDIS_DECODE_RESPONSES,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
redis = sentinel.master_for(
|
|
146
|
+
service_name=sentinel_settings.REDIS_SENTINEL_MASTER_NAME,
|
|
147
|
+
db=sentinel_settings.REDIS_DB,
|
|
148
|
+
decode_responses=sentinel_settings.REDIS_DECODE_RESPONSES,
|
|
149
|
+
retry_on_timeout=sentinel_settings.REDIS_RETRY_TIMEOUT,
|
|
150
|
+
client_name=sentinel_settings.REDIS_CLIENT_NAME,
|
|
151
|
+
max_connections=sentinel_settings.REDIS_MAX_CONNECTIONS,
|
|
152
|
+
health_check_interval=sentinel_settings.REDIS_HEALTH_CHECK_INTERVAL,
|
|
153
|
+
)
|
|
154
|
+
logger.info("Connected to Redis Sentinel")
|
|
155
|
+
|
|
156
|
+
elif standalone_settings.REDIS_HOST:
|
|
157
|
+
pool = aioredis.ConnectionPool(
|
|
158
|
+
host=standalone_settings.REDIS_HOST,
|
|
159
|
+
port=standalone_settings.REDIS_PORT,
|
|
160
|
+
db=standalone_settings.REDIS_DB,
|
|
161
|
+
max_connections=standalone_settings.REDIS_MAX_CONNECTIONS,
|
|
162
|
+
decode_responses=standalone_settings.REDIS_DECODE_RESPONSES,
|
|
163
|
+
retry_on_timeout=standalone_settings.REDIS_RETRY_TIMEOUT,
|
|
164
|
+
health_check_interval=standalone_settings.REDIS_HEALTH_CHECK_INTERVAL,
|
|
165
|
+
)
|
|
166
|
+
redis = aioredis.Redis(
|
|
167
|
+
connection_pool=pool, client_name=standalone_settings.REDIS_CLIENT_NAME
|
|
168
|
+
)
|
|
169
|
+
logger.info("Connected to Redis")
|
|
170
|
+
else:
|
|
171
|
+
logger.warning("No Redis configuration found")
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
return redis
|
|
175
|
+
|
|
176
|
+
except aioredis.ConnectionError as e:
|
|
177
|
+
logger.error(f"Redis connection error: {e}")
|
|
178
|
+
return None
|
|
179
|
+
except aioredis.AuthenticationError as e:
|
|
180
|
+
logger.error(f"Redis authentication error: {e}")
|
|
181
|
+
return None
|
|
182
|
+
except aioredis.TimeoutError as e:
|
|
183
|
+
logger.error(f"Redis timeout error: {e}")
|
|
184
|
+
return None
|
|
185
|
+
except Exception as e:
|
|
186
|
+
logger.error(f"Failed to connect to Redis: {e}")
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def get_redis_key(url: str, token: str) -> str:
|
|
191
|
+
"""Create Redis key using URL path and token."""
|
|
192
|
+
parsed = urlparse(url)
|
|
193
|
+
return f"nav:{parsed.path}:{token}"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def build_url_with_token(base_url: str, token: str) -> str:
|
|
197
|
+
"""Build URL with token parameter."""
|
|
198
|
+
parsed = urlparse(base_url)
|
|
199
|
+
query_params = parse_qs(parsed.query)
|
|
200
|
+
|
|
201
|
+
query_params["token"] = [token]
|
|
202
|
+
|
|
203
|
+
new_query = urlencode(query_params, doseq=True)
|
|
204
|
+
|
|
205
|
+
return urlunparse(
|
|
206
|
+
(
|
|
207
|
+
parsed.scheme,
|
|
208
|
+
parsed.netloc,
|
|
209
|
+
parsed.path,
|
|
210
|
+
parsed.params,
|
|
211
|
+
new_query,
|
|
212
|
+
parsed.fragment,
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def save_prev_link(
|
|
218
|
+
redis: aioredis.Redis, next_url: str, current_url: str, next_token: str
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Save the current page as the previous link for the next URL."""
|
|
221
|
+
if next_url and next_token:
|
|
222
|
+
if sentinel_settings.REDIS_SENTINEL_HOSTS:
|
|
223
|
+
ttl_seconds = sentinel_settings.REDIS_SELF_LINK_TTL
|
|
224
|
+
elif standalone_settings.REDIS_HOST:
|
|
225
|
+
ttl_seconds = standalone_settings.REDIS_SELF_LINK_TTL
|
|
226
|
+
key = get_redis_key(next_url, next_token)
|
|
227
|
+
await redis.setex(key, ttl_seconds, current_url)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def get_prev_link(
|
|
231
|
+
redis: aioredis.Redis, current_url: str, current_token: str
|
|
232
|
+
) -> Optional[str]:
|
|
233
|
+
"""Get the previous page link for the current token."""
|
|
234
|
+
if not current_url or not current_token:
|
|
235
|
+
return None
|
|
236
|
+
key = get_redis_key(current_url, current_token)
|
|
237
|
+
return await redis.get(key)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
async def redis_pagination_links(
|
|
241
|
+
current_url: str, token: str, next_token: str, links: list
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Handle Redis pagination."""
|
|
244
|
+
redis = await connect_redis()
|
|
245
|
+
if not redis:
|
|
246
|
+
logger.warning("Redis connection failed.")
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
if next_token:
|
|
251
|
+
next_url = build_url_with_token(current_url, next_token)
|
|
252
|
+
await save_prev_link(redis, next_url, current_url, next_token)
|
|
253
|
+
|
|
254
|
+
if token:
|
|
255
|
+
prev_link = await get_prev_link(redis, current_url, token)
|
|
256
|
+
if prev_link:
|
|
257
|
+
links.insert(
|
|
258
|
+
0,
|
|
259
|
+
{
|
|
260
|
+
"rel": "previous",
|
|
261
|
+
"type": "application/json",
|
|
262
|
+
"method": "GET",
|
|
263
|
+
"href": prev_link,
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
except Exception as e:
|
|
267
|
+
logger.warning(f"Redis pagination operation failed: {e}")
|
|
268
|
+
finally:
|
|
269
|
+
await redis.close()
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""library version."""
|
|
2
|
-
__version__ = "6.
|
|
2
|
+
__version__ = "6.7.1"
|
|
@@ -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
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/base_database_logic.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/extensions/__init__.py
RENAMED
|
File without changes
|
{stac_fastapi_core-6.6.0 → stac_fastapi_core-6.7.1}/stac_fastapi/core/extensions/aggregation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|