eodag 2.12.0__py3-none-any.whl → 3.0.0b1__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.
Files changed (77) hide show
  1. eodag/api/core.py +434 -319
  2. eodag/api/product/__init__.py +5 -1
  3. eodag/api/product/_assets.py +7 -2
  4. eodag/api/product/_product.py +46 -68
  5. eodag/api/product/metadata_mapping.py +181 -66
  6. eodag/api/search_result.py +21 -1
  7. eodag/cli.py +20 -6
  8. eodag/config.py +95 -6
  9. eodag/plugins/apis/base.py +8 -162
  10. eodag/plugins/apis/ecmwf.py +36 -24
  11. eodag/plugins/apis/usgs.py +40 -24
  12. eodag/plugins/authentication/aws_auth.py +2 -2
  13. eodag/plugins/authentication/header.py +31 -6
  14. eodag/plugins/authentication/keycloak.py +13 -84
  15. eodag/plugins/authentication/oauth.py +3 -3
  16. eodag/plugins/authentication/openid_connect.py +256 -46
  17. eodag/plugins/authentication/qsauth.py +3 -0
  18. eodag/plugins/authentication/sas_auth.py +8 -1
  19. eodag/plugins/authentication/token.py +92 -46
  20. eodag/plugins/authentication/token_exchange.py +120 -0
  21. eodag/plugins/download/aws.py +86 -91
  22. eodag/plugins/download/base.py +72 -40
  23. eodag/plugins/download/http.py +607 -264
  24. eodag/plugins/download/s3rest.py +28 -15
  25. eodag/plugins/manager.py +73 -57
  26. eodag/plugins/search/__init__.py +36 -0
  27. eodag/plugins/search/base.py +225 -18
  28. eodag/plugins/search/build_search_result.py +389 -32
  29. eodag/plugins/search/cop_marine.py +378 -0
  30. eodag/plugins/search/creodias_s3.py +15 -14
  31. eodag/plugins/search/csw.py +5 -7
  32. eodag/plugins/search/data_request_search.py +44 -20
  33. eodag/plugins/search/qssearch.py +508 -203
  34. eodag/plugins/search/static_stac_search.py +99 -36
  35. eodag/resources/constraints/climate-dt.json +13 -0
  36. eodag/resources/constraints/extremes-dt.json +8 -0
  37. eodag/resources/ext_product_types.json +1 -1
  38. eodag/resources/product_types.yml +1897 -34
  39. eodag/resources/providers.yml +3539 -3277
  40. eodag/resources/stac.yml +48 -54
  41. eodag/resources/stac_api.yml +71 -25
  42. eodag/resources/stac_provider.yml +5 -0
  43. eodag/resources/user_conf_template.yml +51 -3
  44. eodag/rest/__init__.py +6 -0
  45. eodag/rest/cache.py +70 -0
  46. eodag/rest/config.py +68 -0
  47. eodag/rest/constants.py +27 -0
  48. eodag/rest/core.py +757 -0
  49. eodag/rest/server.py +397 -258
  50. eodag/rest/stac.py +438 -307
  51. eodag/rest/types/collections_search.py +44 -0
  52. eodag/rest/types/eodag_search.py +232 -43
  53. eodag/rest/types/{stac_queryables.py → queryables.py} +81 -43
  54. eodag/rest/types/stac_search.py +277 -0
  55. eodag/rest/utils/__init__.py +216 -0
  56. eodag/rest/utils/cql_evaluate.py +119 -0
  57. eodag/rest/utils/rfc3339.py +65 -0
  58. eodag/types/__init__.py +99 -9
  59. eodag/types/bbox.py +15 -14
  60. eodag/types/download_args.py +31 -0
  61. eodag/types/search_args.py +58 -7
  62. eodag/types/whoosh.py +81 -0
  63. eodag/utils/__init__.py +72 -9
  64. eodag/utils/constraints.py +37 -37
  65. eodag/utils/exceptions.py +23 -17
  66. eodag/utils/requests.py +138 -0
  67. eodag/utils/rest.py +104 -0
  68. eodag/utils/stac_reader.py +100 -16
  69. {eodag-2.12.0.dist-info → eodag-3.0.0b1.dist-info}/METADATA +64 -44
  70. eodag-3.0.0b1.dist-info/RECORD +109 -0
  71. {eodag-2.12.0.dist-info → eodag-3.0.0b1.dist-info}/WHEEL +1 -1
  72. {eodag-2.12.0.dist-info → eodag-3.0.0b1.dist-info}/entry_points.txt +6 -5
  73. eodag/plugins/apis/cds.py +0 -540
  74. eodag/rest/utils.py +0 -1133
  75. eodag-2.12.0.dist-info/RECORD +0 -94
  76. {eodag-2.12.0.dist-info → eodag-3.0.0b1.dist-info}/LICENSE +0 -0
  77. {eodag-2.12.0.dist-info → eodag-3.0.0b1.dist-info}/top_level.txt +0 -0
eodag/rest/server.py CHANGED
@@ -19,9 +19,11 @@ from __future__ import annotations
19
19
 
20
20
  import logging
21
21
  import os
22
+ import re
22
23
  import traceback
23
24
  from contextlib import asynccontextmanager
24
25
  from importlib.metadata import version
26
+ from json import JSONDecodeError
25
27
  from typing import (
26
28
  TYPE_CHECKING,
27
29
  Any,
@@ -29,37 +31,39 @@ from typing import (
29
31
  Awaitable,
30
32
  Callable,
31
33
  Dict,
32
- List,
33
34
  Optional,
34
- Union,
35
35
  )
36
36
 
37
37
  from fastapi import APIRouter as FastAPIRouter
38
38
  from fastapi import FastAPI, HTTPException, Request
39
- from fastapi.encoders import jsonable_encoder
40
39
  from fastapi.middleware.cors import CORSMiddleware
41
40
  from fastapi.openapi.utils import get_openapi
42
- from fastapi.responses import ORJSONResponse, StreamingResponse
43
- from pydantic import BaseModel
41
+ from fastapi.responses import ORJSONResponse
42
+ from pydantic import ValidationError as pydanticValidationError
43
+ from pygeofilter.backends.cql2_json import to_cql2
44
+ from pygeofilter.parsers.cql2_text import parse as parse_cql2_text
44
45
  from starlette.exceptions import HTTPException as StarletteHTTPException
45
46
 
46
47
  from eodag.config import load_stac_api_config
47
- from eodag.rest.types.stac_queryables import StacQueryables
48
- from eodag.rest.utils import (
49
- download_stac_item_by_id_stream,
48
+ from eodag.rest.cache import init_cache
49
+ from eodag.rest.core import (
50
+ all_collections,
51
+ download_stac_item,
50
52
  eodag_api_init,
51
- fetch_collection_queryable_properties,
53
+ get_collection,
52
54
  get_detailled_collections_list,
55
+ get_queryables,
53
56
  get_stac_api_version,
54
57
  get_stac_catalogs,
55
- get_stac_collection_by_id,
56
- get_stac_collections,
57
58
  get_stac_conformance,
58
59
  get_stac_extension_oseo,
59
- get_stac_item_by_id,
60
60
  search_stac_items,
61
61
  )
62
- from eodag.utils import DEFAULT_ITEMS_PER_PAGE, parse_header, update_nested_dict
62
+ from eodag.rest.types.eodag_search import EODAGSearch
63
+ from eodag.rest.types.queryables import QueryablesGetParams
64
+ from eodag.rest.types.stac_search import SearchPostRequest, sortby2list
65
+ from eodag.rest.utils import format_pydantic_error, str2json, str2list
66
+ from eodag.utils import parse_header, update_nested_dict
63
67
  from eodag.utils.exceptions import (
64
68
  AuthenticationError,
65
69
  DownloadError,
@@ -77,8 +81,15 @@ if TYPE_CHECKING:
77
81
  from fastapi.types import DecoratedCallable
78
82
  from requests import Response
79
83
 
84
+ from starlette.responses import Response as StarletteResponse
80
85
 
81
86
  logger = logging.getLogger("eodag.rest.server")
87
+ ERRORS_WITH_500_STATUS_CODE = {
88
+ "MisconfiguredError",
89
+ "AuthenticationError",
90
+ "DownloadError",
91
+ "RequestError",
92
+ }
82
93
 
83
94
 
84
95
  class APIRouter(FastAPIRouter):
@@ -118,6 +129,7 @@ router = APIRouter()
118
129
  async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
119
130
  """API init and tear-down"""
120
131
  eodag_api_init()
132
+ init_cache(app)
121
133
  yield
122
134
 
123
135
 
@@ -127,14 +139,16 @@ app = FastAPI(lifespan=lifespan, title="EODAG", docs_url="/api.html")
127
139
  stac_api_config = load_stac_api_config()
128
140
 
129
141
 
130
- @router.get("/api", tags=["Capabilities"], include_in_schema=False)
131
- def eodag_openapi() -> Dict[str, Any]:
142
+ @router.api_route(
143
+ methods=["GET", "HEAD"], path="/api", tags=["Capabilities"], include_in_schema=False
144
+ )
145
+ async def eodag_openapi(request: Request) -> Dict[str, Any]:
132
146
  """Customized openapi"""
133
147
  logger.debug("URL: /api")
134
148
  if app.openapi_schema:
135
149
  return app.openapi_schema
136
150
 
137
- root_catalog = get_stac_catalogs(url="", fetch_providers=False)
151
+ root_catalog = await get_stac_catalogs(request=request, url="")
138
152
  stac_api_version = get_stac_api_version()
139
153
 
140
154
  openapi_schema = get_openapi(
@@ -151,11 +165,11 @@ def eodag_openapi() -> Dict[str, Any]:
151
165
  openapi_schema["components"] = stac_api_config["components"]
152
166
  openapi_schema["tags"] = stac_api_config["tags"]
153
167
 
154
- detailled_collections_list = get_detailled_collections_list(fetch_providers=False)
168
+ detailled_collections_list = get_detailled_collections_list()
155
169
 
156
170
  openapi_schema["info"]["description"] = (
157
171
  root_catalog["description"]
158
- + " (stac-api-spec {})".format(stac_api_version)
172
+ + f" (stac-api-spec {stac_api_version})"
159
173
  + "<details><summary>Available collections / product types</summary>"
160
174
  + "".join(
161
175
  [
@@ -195,8 +209,10 @@ async def forward_middleware(
195
209
 
196
210
  if "forwarded" in request.headers:
197
211
  header_forwarded = parse_header(request.headers["forwarded"])
198
- forwarded_host = header_forwarded.get_param("host", None) or forwarded_host
199
- forwarded_proto = header_forwarded.get_param("proto", None) or forwarded_proto
212
+ forwarded_host = str(header_forwarded.get_param("host", None)) or forwarded_host
213
+ forwarded_proto = (
214
+ str(header_forwarded.get_param("proto", None)) or forwarded_proto
215
+ )
200
216
 
201
217
  request.state.url_root = f"{forwarded_proto or request.url.scheme}://{forwarded_host or request.url.netloc}"
202
218
  request.state.url = f"{request.state.url_root}{request.url.path}"
@@ -221,13 +237,30 @@ async def default_exception_handler(
221
237
  )
222
238
 
223
239
 
240
+ @app.exception_handler(ValidationError)
241
+ async def handle_invalid_usage_with_validation_error(
242
+ request: Request, error: ValidationError
243
+ ) -> ORJSONResponse:
244
+ """Invalid usage [400] ValidationError handle"""
245
+ if error.parameters:
246
+ for error_param in error.parameters:
247
+ stac_param = EODAGSearch.to_stac(error_param)
248
+ error.message = error.message.replace(error_param, stac_param)
249
+ logger.debug(traceback.format_exc())
250
+ return await default_exception_handler(
251
+ request,
252
+ HTTPException(
253
+ status_code=400,
254
+ detail=f"{type(error).__name__}: {str(error.message)}",
255
+ ),
256
+ )
257
+
258
+
224
259
  @app.exception_handler(NoMatchingProductType)
225
260
  @app.exception_handler(UnsupportedProductType)
226
261
  @app.exception_handler(UnsupportedProvider)
227
- @app.exception_handler(ValidationError)
228
262
  async def handle_invalid_usage(request: Request, error: Exception) -> ORJSONResponse:
229
263
  """Invalid usage [400] errors handle"""
230
- logger.warning(traceback.format_exc())
231
264
  return await default_exception_handler(
232
265
  request,
233
266
  HTTPException(
@@ -254,8 +287,8 @@ async def handle_resource_not_found(
254
287
  @app.exception_handler(MisconfiguredError)
255
288
  @app.exception_handler(AuthenticationError)
256
289
  async def handle_auth_error(request: Request, error: Exception) -> ORJSONResponse:
257
- """AuthenticationError should be sent as internal server error to the client"""
258
- logger.error(f"{type(error).__name__}: {str(error)}")
290
+ """These errors should be sent as internal server error to the client"""
291
+ logger.error("%s: %s", type(error).__name__, str(error))
259
292
  return await default_exception_handler(
260
293
  request,
261
294
  HTTPException(
@@ -266,9 +299,36 @@ async def handle_auth_error(request: Request, error: Exception) -> ORJSONRespons
266
299
 
267
300
 
268
301
  @app.exception_handler(DownloadError)
302
+ async def handle_download_error(request: Request, error: Exception) -> ORJSONResponse:
303
+ """DownloadError should be sent as internal server error with details to the client"""
304
+ logger.error(f"{type(error).__name__}: {str(error)}")
305
+ return await default_exception_handler(
306
+ request,
307
+ HTTPException(
308
+ status_code=500,
309
+ detail=f"{type(error).__name__}: {str(error)}",
310
+ ),
311
+ )
312
+
313
+
269
314
  @app.exception_handler(RequestError)
270
- async def handle_server_error(request: Request, error: Exception) -> ORJSONResponse:
271
- """These errors should be sent as internal server error with details to the client"""
315
+ async def handle_request_error(request: Request, error: RequestError) -> ORJSONResponse:
316
+ """RequestError should be sent as internal server error with details to the client"""
317
+ if getattr(error, "history", None):
318
+ error_history_tmp = list(error.history)
319
+ for i, search_error in enumerate(error_history_tmp):
320
+ if search_error[1].__class__.__name__ in ERRORS_WITH_500_STATUS_CODE:
321
+ search_error[1].args = ("an internal error occured",)
322
+ error_history_tmp[i] = search_error
323
+ continue
324
+ if getattr(error, "parameters", None):
325
+ for error_param in error.parameters:
326
+ stac_param = EODAGSearch.to_stac(error_param)
327
+ search_error[1].args = (
328
+ search_error[1].args[0].replace(error_param, stac_param),
329
+ )
330
+ error_history_tmp[i] = search_error
331
+ error.history = set(error_history_tmp)
272
332
  logger.error(f"{type(error).__name__}: {str(error)}")
273
333
  return await default_exception_handler(
274
334
  request,
@@ -292,164 +352,166 @@ async def handle_timeout(request: Request, error: Exception) -> ORJSONResponse:
292
352
  )
293
353
 
294
354
 
295
- @router.get("/", tags=["Capabilities"])
296
- def catalogs_root(request: Request) -> Any:
355
+ @router.api_route(methods=["GET", "HEAD"], path="/", tags=["Capabilities"])
356
+ async def catalogs_root(request: Request) -> ORJSONResponse:
297
357
  """STAC catalogs root"""
298
- logger.debug(f"URL: {request.url}")
358
+ logger.debug("URL: %s", request.url)
299
359
 
300
- response = get_stac_catalogs(
360
+ response = await get_stac_catalogs(
361
+ request=request,
301
362
  url=request.state.url,
302
- root=request.state.url_root,
303
- catalogs=[],
304
363
  provider=request.query_params.get("provider", None),
305
364
  )
306
365
 
307
- return jsonable_encoder(response)
366
+ return ORJSONResponse(response)
308
367
 
309
368
 
310
- @router.get("/conformance", tags=["Capabilities"])
311
- def conformance() -> Any:
369
+ @router.api_route(methods=["GET", "HEAD"], path="/conformance", tags=["Capabilities"])
370
+ def conformance() -> ORJSONResponse:
312
371
  """STAC conformance"""
313
372
  logger.debug("URL: /conformance")
314
373
  response = get_stac_conformance()
315
374
 
316
- return jsonable_encoder(response)
375
+ return ORJSONResponse(response)
317
376
 
318
377
 
319
- @router.get("/extensions/oseo/json-schema/schema.json", include_in_schema=False)
320
- def stac_extension_oseo(request: Request) -> Any:
378
+ @router.api_route(
379
+ methods=["GET", "HEAD"],
380
+ path="/extensions/oseo/json-schema/schema.json",
381
+ include_in_schema=False,
382
+ )
383
+ def stac_extension_oseo(request: Request) -> ORJSONResponse:
321
384
  """STAC OGC / OpenSearch extension for EO"""
322
- logger.debug(f"URL: {request.url}")
385
+ logger.debug("URL: %s", request.url)
323
386
  response = get_stac_extension_oseo(url=request.state.url)
324
387
 
325
- return jsonable_encoder(response)
326
-
327
-
328
- class SearchBody(BaseModel):
329
- """
330
- class which describes the body of a search request
331
- """
388
+ return ORJSONResponse(response)
332
389
 
333
- provider: Optional[str] = None
334
- collections: Union[List[str], str]
335
- datetime: Optional[str] = None
336
- bbox: Optional[List[Union[int, float]]] = None
337
- intersects: Optional[Dict[str, Any]] = None
338
- limit: Optional[int] = DEFAULT_ITEMS_PER_PAGE
339
- page: Optional[int] = 1
340
- query: Optional[Dict[str, Any]] = None
341
- ids: Optional[List[str]] = None
342
390
 
343
-
344
- @router.get(
345
- "/collections/{collection_id}/items/{item_id}/download",
391
+ @router.api_route(
392
+ methods=["GET", "HEAD"],
393
+ path="/collections/{collection_id}/items/{item_id}/download",
346
394
  tags=["Data"],
347
395
  include_in_schema=False,
348
396
  )
349
397
  def stac_collections_item_download(
350
398
  collection_id: str, item_id: str, request: Request
351
- ) -> StreamingResponse:
399
+ ) -> StarletteResponse:
352
400
  """STAC collection item download"""
353
- logger.debug(f"URL: {request.url}")
401
+ logger.debug("URL: %s", request.url)
354
402
 
355
403
  arguments = dict(request.query_params)
356
404
  provider = arguments.pop("provider", None)
357
405
 
358
- return download_stac_item_by_id_stream(
359
- catalogs=[collection_id], item_id=item_id, provider=provider, **arguments
406
+ return download_stac_item(
407
+ request=request,
408
+ catalogs=[collection_id],
409
+ item_id=item_id,
410
+ provider=provider,
411
+ **arguments,
360
412
  )
361
413
 
362
414
 
363
- @router.get(
364
- "/collections/{collection_id}/items/{item_id}/download/{asset_filter}",
415
+ @router.api_route(
416
+ methods=["GET", "HEAD"],
417
+ path="/collections/{collection_id}/items/{item_id}/download/{asset}",
365
418
  tags=["Data"],
366
419
  include_in_schema=False,
367
420
  )
368
421
  def stac_collections_item_download_asset(
369
- collection_id, item_id, asset_filter, request: Request
422
+ collection_id: str, item_id: str, asset: str, request: Request
370
423
  ):
371
424
  """STAC collection item asset download"""
372
- logger.debug(f"URL: {request.url}")
425
+ logger.debug("URL: %s", request.url)
373
426
 
374
427
  arguments = dict(request.query_params)
375
428
  provider = arguments.pop("provider", None)
376
429
 
377
- return download_stac_item_by_id_stream(
430
+ return download_stac_item(
431
+ request=request,
378
432
  catalogs=[collection_id],
379
433
  item_id=item_id,
380
434
  provider=provider,
381
- asset=asset_filter,
382
- **arguments,
435
+ asset=asset,
383
436
  )
384
437
 
385
438
 
386
- @router.get(
387
- "/collections/{collection_id}/items/{item_id}",
439
+ @router.api_route(
440
+ methods=["GET", "HEAD"],
441
+ path="/collections/{collection_id}/items/{item_id}",
388
442
  tags=["Data"],
389
443
  include_in_schema=False,
390
444
  )
391
- def stac_collections_item(collection_id: str, item_id: str, request: Request) -> Any:
445
+ def stac_collections_item(
446
+ collection_id: str, item_id: str, request: Request, provider: Optional[str] = None
447
+ ) -> ORJSONResponse:
392
448
  """STAC collection item by id"""
393
- logger.debug(f"URL: {request.url}")
394
- url = request.state.url
395
- url_root = request.state.url_root
449
+ logger.debug("URL: %s", request.url)
396
450
 
397
- arguments = dict(request.query_params)
398
- provider = arguments.pop("provider", None)
399
-
400
- response = get_stac_item_by_id(
401
- url=url,
402
- item_id=item_id,
403
- root=url_root,
404
- catalogs=[collection_id],
405
- provider=provider,
406
- **arguments,
451
+ search_request = SearchPostRequest(
452
+ provider=provider, ids=[item_id], collections=[collection_id], limit=1
407
453
  )
408
454
 
409
- if response:
410
- return jsonable_encoder(response)
411
- else:
455
+ item_collection = search_stac_items(request, search_request)
456
+
457
+ if not item_collection["features"]:
412
458
  raise HTTPException(
413
459
  status_code=404,
414
- detail="No item found matching `{}` id in collection `{}`".format(
415
- item_id, collection_id
416
- ),
460
+ detail=f"Item {item_id} in Collection {collection_id} does not exist.",
417
461
  )
418
462
 
463
+ return ORJSONResponse(item_collection["features"][0])
419
464
 
420
- @router.get(
421
- "/collections/{collection_id}/items",
465
+
466
+ @router.api_route(
467
+ methods=["GET", "HEAD"],
468
+ path="/collections/{collection_id}/items",
422
469
  tags=["Data"],
423
470
  include_in_schema=False,
424
471
  )
425
- def stac_collections_items(collection_id: str, request: Request) -> Any:
426
- """STAC collections items"""
427
- logger.debug(f"URL: {request.url}")
428
- url = request.state.url
429
- url_root = request.state.url_root
430
-
431
- arguments = dict(request.query_params)
432
- provider = arguments.pop("provider", None)
472
+ def stac_collections_items(
473
+ collection_id: str,
474
+ request: Request,
475
+ provider: Optional[str] = None,
476
+ bbox: Optional[str] = None,
477
+ datetime: Optional[str] = None,
478
+ limit: Optional[int] = None,
479
+ query: Optional[str] = None,
480
+ page: Optional[int] = None,
481
+ sortby: Optional[str] = None,
482
+ filter: Optional[str] = None,
483
+ filter_lang: Optional[str] = "cql2-text",
484
+ crunch: Optional[str] = None,
485
+ ) -> ORJSONResponse:
486
+ """Fetch collection's features"""
433
487
 
434
- response = search_stac_items(
435
- url=url,
436
- arguments=arguments,
437
- root=url_root,
488
+ return get_search(
489
+ request=request,
438
490
  provider=provider,
439
- catalogs=[collection_id],
491
+ collections=collection_id,
492
+ bbox=bbox,
493
+ datetime=datetime,
494
+ limit=limit,
495
+ query=query,
496
+ page=page,
497
+ sortby=sortby,
498
+ filter=filter,
499
+ filter_lang=filter_lang,
500
+ crunch=crunch,
440
501
  )
441
- return jsonable_encoder(response)
442
502
 
443
503
 
444
- @router.get(
445
- "/collections/{collection_id}/queryables",
504
+ @router.api_route(
505
+ methods=["GET", "HEAD"],
506
+ path="/collections/{collection_id}/queryables",
446
507
  tags=["Capabilities"],
447
508
  include_in_schema=False,
448
509
  response_model_exclude_none=True,
449
510
  )
450
- def list_collection_queryables(
451
- request: Request, collection_id: str, provider: Optional[str] = None
452
- ) -> Any:
511
+ async def list_collection_queryables(
512
+ request: Request,
513
+ collection_id: str,
514
+ ) -> ORJSONResponse:
453
515
  """Returns the list of queryable properties for a specific collection.
454
516
 
455
517
  This endpoint provides a list of properties that can be used as filters when querying
@@ -460,118 +522,117 @@ def list_collection_queryables(
460
522
  :type request: fastapi.Request
461
523
  :param collection_id: The identifier of the collection for which to retrieve queryable properties.
462
524
  :type collection_id: str
463
- :param provider: (optional) The provider for which to retrieve additional properties.
464
- :type provider: str
465
525
  :returns: A json object containing the list of available queryable properties for the specified collection.
466
526
  :rtype: Any
467
527
  """
468
528
  logger.debug(f"URL: {request.url}")
469
- query_params = request.query_params.items()
470
- additional_params = dict(query_params)
471
- additional_params.pop("provider", None)
472
-
473
- queryables = StacQueryables(q_id=request.state.url, additional_properties=False)
529
+ additional_params = dict(request.query_params)
530
+ provider = additional_params.pop("provider", None)
474
531
 
475
- collection_queryables = fetch_collection_queryable_properties(
476
- collection_id, provider, **additional_params
532
+ queryables = await get_queryables(
533
+ request,
534
+ QueryablesGetParams(collection=collection_id, **additional_params),
535
+ provider=provider,
477
536
  )
478
- for key, collection_queryable in collection_queryables.items():
479
- queryables[key] = collection_queryable
480
- queryables.properties.pop("collections")
481
537
 
482
- return jsonable_encoder(queryables)
538
+ return ORJSONResponse(queryables)
483
539
 
484
540
 
485
- @router.get(
486
- "/collections/{collection_id}",
541
+ @router.api_route(
542
+ methods=["GET", "HEAD"],
543
+ path="/collections/{collection_id}",
487
544
  tags=["Capabilities"],
488
545
  include_in_schema=False,
489
546
  )
490
- def collection_by_id(collection_id: str, request: Request) -> Any:
547
+ async def collection_by_id(
548
+ collection_id: str, request: Request, provider: Optional[str] = None
549
+ ) -> ORJSONResponse:
491
550
  """STAC collection by id"""
492
- logger.debug(f"URL: {request.url}")
493
- url = request.state.url_root + "/collections"
494
- url_root = request.state.url_root
551
+ logger.debug("URL: %s", request.url)
495
552
 
496
- arguments = dict(request.query_params)
497
- provider = arguments.pop("provider", None)
498
-
499
- response = get_stac_collection_by_id(
500
- url=url,
501
- root=url_root,
553
+ response = await get_collection(
554
+ request=request,
502
555
  collection_id=collection_id,
503
556
  provider=provider,
504
557
  )
505
558
 
506
- return jsonable_encoder(response)
559
+ return ORJSONResponse(response)
507
560
 
508
561
 
509
- @router.get(
510
- "/collections",
562
+ @router.api_route(
563
+ methods=["GET", "HEAD"],
564
+ path="/collections",
511
565
  tags=["Capabilities"],
512
566
  include_in_schema=False,
513
567
  )
514
- def collections(request: Request) -> Any:
568
+ async def collections(
569
+ request: Request,
570
+ provider: Optional[str] = None,
571
+ q: Optional[str] = None,
572
+ platform: Optional[str] = None,
573
+ instrument: Optional[str] = None,
574
+ constellation: Optional[str] = None,
575
+ datetime: Optional[str] = None,
576
+ ) -> ORJSONResponse:
515
577
  """STAC collections
516
578
 
517
- Can be filtered using parameters: instrument, platform, platformSerialIdentifier, sensorType, processingLevel
579
+ Can be filtered using parameters: instrument, platform, platformSerialIdentifier, sensorType,
580
+ processingLevel
518
581
  """
519
- logger.debug(f"URL: {request.url}")
520
- url = request.state.url
521
- url_root = request.state.url_root
582
+ logger.debug("URL: %s", request.url)
522
583
 
523
- arguments = dict(request.query_params)
524
- provider = arguments.pop("provider", None)
525
-
526
- response = get_stac_collections(
527
- url=url,
528
- root=url_root,
529
- arguments=arguments,
530
- provider=provider,
584
+ collections = await all_collections(
585
+ request, provider, q, platform, instrument, constellation, datetime
531
586
  )
587
+ return ORJSONResponse(collections)
532
588
 
533
- return jsonable_encoder(response)
534
589
 
535
-
536
- @router.get(
537
- "/catalogs/{catalogs:path}/items/{item_id}/download",
590
+ @router.api_route(
591
+ methods=["GET", "HEAD"],
592
+ path="/catalogs/{catalogs:path}/items/{item_id}/download",
538
593
  tags=["Data"],
539
594
  include_in_schema=False,
540
595
  )
541
596
  def stac_catalogs_item_download(
542
597
  catalogs: str, item_id: str, request: Request
543
- ) -> StreamingResponse:
598
+ ) -> StarletteResponse:
544
599
  """STAC Catalog item download"""
545
- logger.debug(f"URL: {request.url}")
600
+ logger.debug("URL: %s", request.url)
546
601
 
547
602
  arguments = dict(request.query_params)
548
603
  provider = arguments.pop("provider", None)
549
604
 
550
605
  list_catalog = catalogs.strip("/").split("/")
551
606
 
552
- return download_stac_item_by_id_stream(
553
- catalogs=list_catalog, item_id=item_id, provider=provider, **arguments
607
+ return download_stac_item(
608
+ request=request,
609
+ catalogs=list_catalog,
610
+ item_id=item_id,
611
+ provider=provider,
612
+ **arguments,
554
613
  )
555
614
 
556
615
 
557
- @router.get(
558
- "/catalogs/{catalogs:path}/items/{item_id}/download/{asset_filter}",
616
+ @router.api_route(
617
+ methods=["GET", "HEAD"],
618
+ path="/catalogs/{catalogs:path}/items/{item_id}/download/{asset_filter}",
559
619
  tags=["Data"],
560
620
  include_in_schema=False,
561
621
  )
562
622
  def stac_catalogs_item_download_asset(
563
- catalogs, item_id, asset_filter, request: Request
623
+ catalogs: str, item_id: str, asset_filter: str, request: Request
564
624
  ):
565
625
  """STAC Catalog item asset download"""
566
- logger.debug(f"URL: {request.url}")
626
+ logger.debug("URL: %s", request.url)
567
627
 
568
628
  arguments = dict(request.query_params)
569
629
  provider = arguments.pop("provider", None)
570
630
 
571
- catalogs = catalogs.strip("/").split("/")
631
+ list_catalog = catalogs.strip("/").split("/")
572
632
 
573
- return download_stac_item_by_id_stream(
574
- catalogs=catalogs,
633
+ return download_stac_item(
634
+ request,
635
+ catalogs=list_catalog,
575
636
  item_id=item_id,
576
637
  provider=provider,
577
638
  asset=asset_filter,
@@ -579,99 +640,116 @@ def stac_catalogs_item_download_asset(
579
640
  )
580
641
 
581
642
 
582
- @router.get(
583
- "/catalogs/{catalogs:path}/items/{item_id}",
643
+ @router.api_route(
644
+ methods=["GET", "HEAD"],
645
+ path="/catalogs/{catalogs:path}/items/{item_id}",
584
646
  tags=["Data"],
585
647
  include_in_schema=False,
586
648
  )
587
- def stac_catalogs_item(catalogs: str, item_id: str, request: Request):
649
+ def stac_catalogs_item(
650
+ catalogs: str, item_id: str, request: Request, provider: Optional[str] = None
651
+ ):
588
652
  """Fetch catalog's single features."""
589
- logger.debug(f"URL: {request.url}")
590
- url = request.state.url
591
- url_root = request.state.url_root
592
-
593
- arguments = dict(request.query_params)
594
- provider = arguments.pop("provider", None)
653
+ logger.debug("URL: %s", request.url)
595
654
 
596
655
  list_catalog = catalogs.strip("/").split("/")
597
- response = get_stac_item_by_id(
598
- url=url,
599
- item_id=item_id,
600
- root=url_root,
601
- catalogs=list_catalog,
602
- provider=provider,
603
- **arguments,
604
- )
605
656
 
606
- if response:
607
- return jsonable_encoder(response)
608
- else:
657
+ search_request = SearchPostRequest(provider=provider, ids=[item_id], limit=1)
658
+
659
+ item_collection = search_stac_items(request, search_request, catalogs=list_catalog)
660
+
661
+ if not item_collection["features"]:
609
662
  raise HTTPException(
610
663
  status_code=404,
611
- detail="No item found matching `{}` id in catalog `{}`".format(
612
- item_id, catalogs
613
- ),
664
+ detail=f"Item {item_id} in Catalog {catalogs} does not exist.",
614
665
  )
615
666
 
667
+ return ORJSONResponse(item_collection["features"][0])
616
668
 
617
- @router.get(
618
- "/catalogs/{catalogs:path}/items",
669
+
670
+ @router.api_route(
671
+ methods=["GET", "HEAD"],
672
+ path="/catalogs/{catalogs:path}/items",
619
673
  tags=["Data"],
620
674
  include_in_schema=False,
621
675
  )
622
- def stac_catalogs_items(catalogs: str, request: Request) -> Any:
623
- """Fetch catalog's features
624
- '"""
625
- logger.debug(f"URL: {request.url}")
626
- url = request.state.url
627
- url_root = request.state.url_root
676
+ def stac_catalogs_items(
677
+ catalogs: str,
678
+ request: Request,
679
+ provider: Optional[str] = None,
680
+ bbox: Optional[str] = None,
681
+ datetime: Optional[str] = None,
682
+ limit: Optional[int] = None,
683
+ page: Optional[int] = None,
684
+ sortby: Optional[str] = None,
685
+ crunch: Optional[str] = None,
686
+ ) -> ORJSONResponse:
687
+ """Fetch catalog's features"""
688
+ logger.debug("URL: %s", request.state.url)
628
689
 
629
- arguments = dict(request.query_params)
630
- provider = arguments.pop("provider", None)
690
+ base_args = {
691
+ "provider": provider,
692
+ "datetime": datetime,
693
+ "bbox": str2list(bbox),
694
+ "limit": limit,
695
+ "page": page,
696
+ "sortby": sortby2list(sortby),
697
+ "crunch": crunch,
698
+ }
699
+
700
+ clean = {k: v for k, v in base_args.items() if v is not None and v != []}
631
701
 
632
702
  list_catalog = catalogs.strip("/").split("/")
633
703
 
704
+ try:
705
+ search_request = SearchPostRequest.model_validate(clean)
706
+ except pydanticValidationError as e:
707
+ raise HTTPException(status_code=400, detail=format_pydantic_error(e)) from e
708
+
634
709
  response = search_stac_items(
635
- url=url,
636
- arguments=arguments,
637
- root=url_root,
710
+ request=request,
711
+ search_request=search_request,
638
712
  catalogs=list_catalog,
639
- provider=provider,
640
713
  )
641
- return jsonable_encoder(response)
714
+ return ORJSONResponse(response)
642
715
 
643
716
 
644
- @router.get(
645
- "/catalogs/{catalogs:path}",
717
+ @router.api_route(
718
+ methods=["GET", "HEAD"],
719
+ path="/catalogs/{catalogs:path}",
646
720
  tags=["Capabilities"],
647
721
  include_in_schema=False,
648
722
  )
649
- def stac_catalogs(catalogs: str, request: Request) -> Any:
723
+ async def stac_catalogs(
724
+ catalogs: str, request: Request, provider: Optional[str] = None
725
+ ) -> ORJSONResponse:
650
726
  """Describe the given catalog and list available sub-catalogs"""
651
- logger.debug(f"URL: {request.url}")
652
- url = request.state.url
653
- url_root = request.state.url_root
727
+ logger.debug("URL: %s", request.url)
654
728
 
655
- arguments = dict(request.query_params)
656
- provider = arguments.pop("provider", None)
729
+ if not catalogs:
730
+ raise HTTPException(
731
+ status_code=404,
732
+ detail="Not found",
733
+ )
657
734
 
658
735
  list_catalog = catalogs.strip("/").split("/")
659
- response = get_stac_catalogs(
660
- url=url,
661
- root=url_root,
662
- catalogs=list_catalog,
736
+ response = await get_stac_catalogs(
737
+ request=request,
738
+ url=request.state.url,
739
+ catalogs=tuple(list_catalog),
663
740
  provider=provider,
664
741
  )
665
- return jsonable_encoder(response)
742
+ return ORJSONResponse(response)
666
743
 
667
744
 
668
- @router.get(
669
- "/queryables",
745
+ @router.api_route(
746
+ methods=["GET", "HEAD"],
747
+ path="/queryables",
670
748
  tags=["Capabilities"],
671
749
  response_model_exclude_none=True,
672
750
  include_in_schema=False,
673
751
  )
674
- def list_queryables(request: Request, provider: Optional[str] = None) -> Any:
752
+ async def list_queryables(request: Request) -> ORJSONResponse:
675
753
  """Returns the list of terms available for use when writing filter expressions.
676
754
 
677
755
  This endpoint provides a list of terms that can be used as filters when querying
@@ -684,57 +762,118 @@ def list_queryables(request: Request, provider: Optional[str] = None) -> Any:
684
762
  :rtype: Any
685
763
  """
686
764
  logger.debug(f"URL: {request.url}")
687
- query_params = request.query_params.items()
688
- additional_params = dict(query_params)
689
- additional_params.pop("provider", None)
690
- queryables = StacQueryables(q_id=request.state.url)
691
- if provider:
692
- queryables.properties.update(
693
- fetch_collection_queryable_properties(None, provider, **additional_params)
694
- )
765
+ additional_params = dict(request.query_params.items())
766
+ provider = additional_params.pop("provider", None)
767
+ queryables = await get_queryables(
768
+ request, QueryablesGetParams(**additional_params), provider=provider
769
+ )
695
770
 
696
- return jsonable_encoder(queryables)
771
+ return ORJSONResponse(queryables)
697
772
 
698
773
 
699
- @router.get(
700
- "/search",
774
+ @router.api_route(
775
+ methods=["GET", "HEAD"],
776
+ path="/search",
701
777
  tags=["STAC"],
702
778
  include_in_schema=False,
703
779
  )
704
- @router.post(
705
- "/search",
780
+ def get_search(
781
+ request: Request,
782
+ provider: Optional[str] = None,
783
+ collections: Optional[str] = None,
784
+ ids: Optional[str] = None,
785
+ bbox: Optional[str] = None,
786
+ datetime: Optional[str] = None,
787
+ intersects: Optional[str] = None,
788
+ limit: Optional[int] = None,
789
+ query: Optional[str] = None,
790
+ page: Optional[int] = None,
791
+ sortby: Optional[str] = None,
792
+ filter: Optional[str] = None, # pylint: disable=redefined-builtin
793
+ filter_lang: Optional[str] = "cql2-text",
794
+ crunch: Optional[str] = None,
795
+ ) -> ORJSONResponse:
796
+ """Handler for GET /search"""
797
+ logger.debug("URL: %s", request.state.url)
798
+
799
+ query_params = str(request.query_params)
800
+
801
+ # Kludgy fix because using factory does not allow alias for filter-lang
802
+ if filter_lang is None:
803
+ match = re.search(r"filter-lang=([a-z0-9-]+)", query_params, re.IGNORECASE)
804
+ if match:
805
+ filter_lang = match.group(1)
806
+
807
+ base_args = {
808
+ "provider": provider,
809
+ "collections": str2list(collections),
810
+ "ids": str2list(ids),
811
+ "datetime": datetime,
812
+ "bbox": str2list(bbox),
813
+ "intersects": str2json("intersects", intersects),
814
+ "limit": limit,
815
+ "query": str2json("query", query),
816
+ "page": page,
817
+ "sortby": sortby2list(sortby),
818
+ "crunch": crunch,
819
+ }
820
+
821
+ if filter:
822
+ if filter_lang == "cql2-text":
823
+ ast = parse_cql2_text(filter)
824
+ base_args["filter"] = str2json("filter", to_cql2(ast)) # type: ignore
825
+ base_args["filter-lang"] = "cql2-json"
826
+ elif filter_lang == "cql-json":
827
+ base_args["filter"] = str2json(filter)
828
+
829
+ clean = {k: v for k, v in base_args.items() if v is not None and v != []}
830
+
831
+ try:
832
+ search_request = SearchPostRequest.model_validate(clean)
833
+ except pydanticValidationError as e:
834
+ raise HTTPException(status_code=400, detail=format_pydantic_error(e)) from e
835
+
836
+ response = search_stac_items(
837
+ request=request,
838
+ search_request=search_request,
839
+ )
840
+ return ORJSONResponse(content=response, media_type="application/json")
841
+
842
+
843
+ @router.api_route(
844
+ methods=["POST", "HEAD"],
845
+ path="/search",
706
846
  tags=["STAC"],
707
847
  include_in_schema=False,
708
848
  )
709
- def stac_search(
710
- request: Request, search_body: Optional[SearchBody] = None
711
- ) -> ORJSONResponse:
712
- """STAC collections items"""
713
- logger.debug(f"URL: {request.url}")
714
- logger.debug(f"Body: {search_body}")
849
+ async def post_search(request: Request) -> ORJSONResponse:
850
+ """STAC post search"""
851
+ logger.debug("URL: %s", request.url)
715
852
 
716
- url = request.state.url
717
- url_root = request.state.url_root
853
+ content_type = request.headers.get("Content-Type")
718
854
 
719
- if search_body is None:
720
- body = {}
721
- else:
722
- body = vars(search_body)
855
+ if content_type is None:
856
+ raise HTTPException(status_code=400, detail="No Content-Type provided")
857
+ if content_type != "application/json":
858
+ raise HTTPException(status_code=400, detail="Content-Type not supported")
723
859
 
724
- arguments = dict(request.query_params, **body)
725
- provider = arguments.pop("provider", None)
860
+ try:
861
+ payload = await request.json()
862
+ except JSONDecodeError as e:
863
+ raise HTTPException(status_code=400, detail="Invalid JSON data") from e
864
+
865
+ try:
866
+ search_request = SearchPostRequest.model_validate(payload)
867
+ except pydanticValidationError as e:
868
+ raise HTTPException(status_code=400, detail=format_pydantic_error(e)) from e
869
+
870
+ logger.debug("Body: %s", search_request.model_dump(exclude_none=True))
726
871
 
727
872
  response = search_stac_items(
728
- url=url,
729
- arguments=arguments,
730
- root=url_root,
731
- provider=provider,
732
- method=request.method,
733
- )
734
- resp = ORJSONResponse(
735
- content=response, status_code=200, media_type="application/json"
873
+ request=request,
874
+ search_request=search_request,
736
875
  )
737
- return resp
876
+ return ORJSONResponse(content=response, media_type="application/json")
738
877
 
739
878
 
740
879
  app.include_router(router)