Skip to content

Commit d456bb1

Browse files
dalpassosbrunato
andauthored
feat: validate search and order requests (#56)
Adopt EODAG's request validation. The validation is enabled via configuration (enabled by default). Endpoint where validation is enabled: - `/search` - `/collections/{collection_id}/order` --------- Co-authored-by: Sylvain Brunato <sylvain.brunato@c-s.fr>
1 parent ec200f5 commit d456bb1

5 files changed

Lines changed: 239 additions & 6 deletions

File tree

stac_fastapi/eodag/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ class Settings(ApiSettings):
6565
validate_default=False,
6666
)
6767

68+
validate_request: bool = Field(
69+
default=True,
70+
description="Validate search and product order requests",
71+
alias="validate",
72+
)
73+
6874

6975
@lru_cache(maxsize=1)
7076
def get_settings() -> Settings:

stac_fastapi/eodag/core.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,10 @@ def _search_base(self, search_request: BaseSearchPostRequest, request: Request)
169169

170170
request.state.eodag_args = eodag_args
171171

172+
# validate request
173+
settings = get_settings()
174+
validate: bool = settings.validate_request
175+
172176
# check if the collection exists
173177
if collection := eodag_args.get("collection"):
174178
all_pt = request.app.state.dag.list_collections(fetch_providers=False)
@@ -184,11 +188,11 @@ def _search_base(self, search_request: BaseSearchPostRequest, request: Request)
184188
search_result = SearchResult([])
185189
for item_id in ids:
186190
eodag_args["id"] = item_id
187-
search_result.extend(request.app.state.dag.search(**eodag_args))
191+
search_result.extend(request.app.state.dag.search(validate=validate, **eodag_args))
188192
search_result.number_matched = len(search_result)
189193
else:
190194
# search without ids
191-
search_result = request.app.state.dag.search(**eodag_args)
195+
search_result = request.app.state.dag.search(validate=validate, **eodag_args)
192196

193197
if search_result.errors and not len(search_result):
194198
raise ResponseSearchError(search_result.errors, self.stac_metadata_model)
@@ -561,7 +565,7 @@ def prepare_search_base_args(search_request: BaseSearchPostRequest, model: type[
561565
562566
:param search_request: the search request
563567
:param model: the model used to validate stac metadata
564-
:returns: a dictionnary containing arguments for the eodag search
568+
:returns: a dictionary containing arguments for the eodag search
565569
"""
566570
base_args = (
567571
{

stac_fastapi/eodag/extensions/collection_order.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
from stac_fastapi.types.search import APIRequest
3737
from stac_fastapi.types.stac import Item
3838

39+
from stac_fastapi.eodag.config import get_settings
40+
from stac_fastapi.eodag.errors import ResponseSearchError
3941
from stac_fastapi.eodag.models.stac_metadata import (
4042
CommonStacMetadata,
4143
create_stac_item,
@@ -81,18 +83,27 @@ def order_collection(
8183

8284
if request_body is None:
8385
federation_backend = None
84-
request_params = {}
86+
search_params = {}
8587
else:
8688
federation_backend = request_body.federation_backends[0] if request_body.federation_backends else None
8789

8890
request_params = request_body.model_dump(exclude={"federation_backends": True})
8991
# use eodag formatting for search
9092
search_params = {f"ecmwf:{k}": v for k, v in request_params.items()}
91-
search_results = dag.search(collection=collection_id, provider=federation_backend, **search_params)
93+
94+
settings = get_settings()
95+
validate: bool = settings.validate_request
96+
search_results = dag.search(
97+
collection=collection_id,
98+
provider=federation_backend,
99+
validate=validate,
100+
**search_params,
101+
)
92102

93103
if len(search_results) > 0:
94104
product = cast(EOProduct, search_results[0])
95-
105+
elif search_results.errors:
106+
raise ResponseSearchError(search_results.errors, self.stac_metadata_model)
96107
else:
97108
raise NotFoundError(
98109
f"Could not find any item in {collection_id} collection for backend {federation_backend}.",

tests/test_order.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
from eodag.config import load_default_config
2828
from eodag.plugins.download.base import Download
2929
from eodag.plugins.manager import PluginManager
30+
from eodag.utils.exceptions import ValidationError
31+
32+
from stac_fastapi.eodag.config import get_settings
3033

3134

3235
@pytest.mark.parametrize("post_data", [{"foo": "bar"}, {}])
@@ -99,6 +102,7 @@ async def run():
99102
collection=collection_id,
100103
provider=None,
101104
**{f"ecmwf:{k}": v for k, v in post_data.items()},
105+
validate=True,
102106
),
103107
)
104108

@@ -190,6 +194,7 @@ async def run():
190194
collection=collection_id,
191195
provider=None,
192196
**{f"ecmwf:{k}": v for k, v in post_data.items()},
197+
validate=True,
193198
),
194199
)
195200

@@ -336,3 +341,128 @@ async def test_order_not_order_id_ko(request_not_found, mock_search, mock_order)
336341
post_data={},
337342
error_message="Download order failed.",
338343
)
344+
345+
346+
@pytest.mark.parametrize("validate", [True, False])
347+
async def test_order_validate(request_valid, settings_cache_clear, validate):
348+
"""Product order through eodag server must be validated according to settings"""
349+
get_settings().validate_request = validate
350+
post_data = {"foo": "bar"}
351+
federation_backend = "cop_ads"
352+
collection_id = "CAMS_EAC4"
353+
expected_search_kwargs = dict(
354+
collection=collection_id,
355+
provider=None,
356+
validate=validate,
357+
**{f"ecmwf:{k}": v for k, v in post_data.items()},
358+
)
359+
url = f"collections/{collection_id}/order"
360+
product = EOProduct(
361+
federation_backend,
362+
dict(
363+
geometry="POINT (0 0)",
364+
title="dummy_product",
365+
id="dummy_id",
366+
),
367+
)
368+
product.collection = collection_id
369+
370+
product_dataset = "cams-global-reanalysis-eac4"
371+
endpoint = "https://ads.atmosphere.copernicus.eu/api/retrieve/v1"
372+
product.properties["eodag:order_link"] = (
373+
f"{endpoint}/processes/{product_dataset}/execution" + '?{"inputs": {"qux": "quux"}}'
374+
)
375+
376+
# order an offline product
377+
product.properties["order:status"] = OFFLINE_STATUS
378+
379+
# add auth and download plugins to make the order works
380+
plugins_manager = PluginManager(load_default_config())
381+
download_plugin = plugins_manager.get_download_plugin(product)
382+
auth_plugin = plugins_manager.get_auth_plugin(download_plugin, product)
383+
auth_plugin.config.credentials = {"apikey": "anicekey"}
384+
product.register_downloader(download_plugin, auth_plugin)
385+
386+
product_id = product.properties["id"]
387+
388+
@responses.activate(registry=responses.registries.OrderedRegistry)
389+
async def run():
390+
responses.add(
391+
responses.POST,
392+
f"{endpoint}/processes/{product_dataset}/execution",
393+
status=200,
394+
content_type="application/json",
395+
body=f'{{"status": "accepted", "jobID": "{product_id}"}}'.encode("utf-8"),
396+
auto_calculate_content_length=True,
397+
)
398+
responses.add(
399+
responses.GET,
400+
f"{endpoint}/jobs/{product_id}",
401+
status=200,
402+
content_type="application/json",
403+
body=f'{{"status": "successful", "jobID": "{product_id}"}}'.encode("utf-8"),
404+
auto_calculate_content_length=True,
405+
)
406+
responses.add(
407+
responses.GET,
408+
f"{endpoint}/jobs/{product_id}/results",
409+
status=200,
410+
content_type="application/json",
411+
body=(f'{{"asset": {{"value": {{"href": "http://somewhere/download/{product_id}"}} }} }}'.encode("utf-8")),
412+
auto_calculate_content_length=True,
413+
)
414+
415+
await request_valid(
416+
url=url,
417+
method="POST",
418+
post_data=post_data,
419+
search_result=SearchResult([product]),
420+
expected_search_kwargs=expected_search_kwargs,
421+
)
422+
423+
await run()
424+
425+
426+
async def test_order_validate_with_errors(app, app_client, mocker, settings_cache_clear):
427+
"""Order a product through eodag server with invalid parameters must return informative error message"""
428+
get_settings().validate_request = True
429+
collection_id = "AG_ERA5"
430+
errors = [
431+
("wekeo_ecmwf", ValidationError("2 error(s). ecmwf:version: Field required; ecmwf:variable: Field required")),
432+
("cop_cds", ValidationError("2 error(s). ecmwf:version: Field required; ecmwf:variable: Field required")),
433+
]
434+
expected_response = {
435+
"code": "400",
436+
"description": "Something went wrong",
437+
"errors": [
438+
{
439+
"provider": "wekeo_ecmwf",
440+
"error": "ValidationError",
441+
"status_code": 400,
442+
"message": "2 error(s). ecmwf:version: Field required; ecmwf:variable: Field required",
443+
},
444+
{
445+
"provider": "cop_cds",
446+
"error": "ValidationError",
447+
"status_code": 400,
448+
"message": "2 error(s). ecmwf:version: Field required; ecmwf:variable: Field required",
449+
},
450+
],
451+
}
452+
453+
mock_search = mocker.patch.object(app.state.dag, "search")
454+
mock_search.return_value = SearchResult([], 0, errors)
455+
456+
response = await app_client.request(
457+
"POST",
458+
f"/collections/{collection_id}/order",
459+
json=None,
460+
follow_redirects=True,
461+
headers={},
462+
)
463+
response_content = response.json()
464+
465+
assert response.status_code == 400
466+
assert "ticket" in response_content
467+
response_content.pop("ticket", None)
468+
assert expected_response == response_content

0 commit comments

Comments
 (0)