diff --git a/stac_fastapi/eodag/config.py b/stac_fastapi/eodag/config.py index 5880969..121b12b 100644 --- a/stac_fastapi/eodag/config.py +++ b/stac_fastapi/eodag/config.py @@ -65,6 +65,12 @@ class Settings(ApiSettings): validate_default=False, ) + validate_request: bool = Field( + default=True, + description="Validate search and product order requests", + alias="validate", + ) + @lru_cache(maxsize=1) def get_settings() -> Settings: diff --git a/stac_fastapi/eodag/core.py b/stac_fastapi/eodag/core.py index c3a4c6b..3fb4ca9 100644 --- a/stac_fastapi/eodag/core.py +++ b/stac_fastapi/eodag/core.py @@ -160,6 +160,10 @@ def _search_base(self, search_request: BaseSearchPostRequest, request: Request) request.state.eodag_args = eodag_args + # validate request + settings = get_settings() + validate: bool = settings.validate_request + # check if the collection exists if product_type := eodag_args.get("productType"): all_pt = request.app.state.dag.list_product_types(fetch_providers=False) @@ -175,11 +179,11 @@ def _search_base(self, search_request: BaseSearchPostRequest, request: Request) search_result = SearchResult([]) for item_id in ids: eodag_args["id"] = item_id - search_result.extend(request.app.state.dag.search(**eodag_args)) + search_result.extend(request.app.state.dag.search(validate=validate, **eodag_args)) search_result.number_matched = len(search_result) else: # search without ids - search_result = request.app.state.dag.search(**eodag_args) + search_result = request.app.state.dag.search(validate=validate, **eodag_args) if search_result.errors and not len(search_result): raise ResponseSearchError(search_result.errors, self.stac_metadata_model) @@ -552,7 +556,7 @@ def prepare_search_base_args(search_request: BaseSearchPostRequest, model: type[ :param search_request: the search request :param model: the model used to validate stac metadata - :returns: a dictionnary containing arguments for the eodag search + :returns: a dictionary containing arguments for the eodag search """ base_args = ( { diff --git a/stac_fastapi/eodag/extensions/collection_order.py b/stac_fastapi/eodag/extensions/collection_order.py index 199d17d..f69889d 100644 --- a/stac_fastapi/eodag/extensions/collection_order.py +++ b/stac_fastapi/eodag/extensions/collection_order.py @@ -36,6 +36,8 @@ from stac_fastapi.types.search import APIRequest from stac_fastapi.types.stac import Item +from stac_fastapi.eodag.config import get_settings +from stac_fastapi.eodag.errors import ResponseSearchError from stac_fastapi.eodag.models.stac_metadata import ( CommonStacMetadata, create_stac_item, @@ -86,11 +88,20 @@ def order_collection( federation_backend = request_body.federation_backends[0] if request_body.federation_backends else None request_params = request_body.model_dump(exclude={"federation_backends": True}) - search_results = dag.search(productType=collection_id, provider=federation_backend, **request_params) + + settings = get_settings() + validate: bool = settings.validate_request + search_results = dag.search( + productType=collection_id, + provider=federation_backend, + validate=validate, + **request_params, + ) if len(search_results) > 0: product = cast(EOProduct, search_results[0]) - + elif search_results.errors: + raise ResponseSearchError(search_results.errors, self.stac_metadata_model) else: raise NotFoundError( f"Could not find any item in {collection_id} collection for backend {federation_backend}.", diff --git a/tests/test_order.py b/tests/test_order.py index f4d502d..e433e88 100644 --- a/tests/test_order.py +++ b/tests/test_order.py @@ -27,6 +27,9 @@ from eodag.config import load_default_config from eodag.plugins.download.base import Download from eodag.plugins.manager import PluginManager +from eodag.utils.exceptions import ValidationError + +from stac_fastapi.eodag.config import get_settings @pytest.mark.parametrize("post_data", [{"foo": "bar"}, {}]) @@ -96,6 +99,7 @@ async def run(): expected_search_kwargs=dict( productType=collection_id, provider=None, + validate=True, **post_data, ), ) @@ -186,6 +190,7 @@ async def run(): expected_search_kwargs=dict( productType=collection_id, provider=None, + validate=True, **post_data, ), ) @@ -334,3 +339,126 @@ async def test_order_not_order_id_ko(request_not_found, mock_search, mock_order) post_data={}, error_message="Download order failed.", ) + + +@pytest.mark.parametrize("validate", [True, False]) +async def test_order_validate(request_valid, settings_cache_clear, validate): + """Product order through eodag server must be validated according to settings""" + get_settings().validate_request = validate + post_data = {"foo": "bar"} + federation_backend = "cop_ads" + collection_id = "CAMS_EAC4" + expected_search_kwargs = dict( + productType=collection_id, + provider=None, + validate=validate, + **post_data, + ) + url = f"collections/{collection_id}/order" + product = EOProduct( + federation_backend, + dict( + geometry="POINT (0 0)", + title="dummy_product", + id="dummy_id", + ), + ) + product.product_type = collection_id + + product_dataset = "cams-global-reanalysis-eac4" + endpoint = "https://ads.atmosphere.copernicus.eu/api/retrieve/v1" + product.properties["orderLink"] = f"{endpoint}/processes/{product_dataset}/execution" + '?{"qux": "quux"}' + + # order an offline product + product.properties["storageStatus"] = OFFLINE_STATUS + + # add auth and download plugins to make the order works + plugins_manager = PluginManager(load_default_config()) + download_plugin = plugins_manager.get_download_plugin(product) + auth_plugin = plugins_manager.get_auth_plugin(download_plugin, product) + auth_plugin.config.credentials = {"apikey": "anicekey"} + product.register_downloader(download_plugin, auth_plugin) + + product_id = product.properties["id"] + + @responses.activate(registry=responses.registries.OrderedRegistry) + async def run(): + responses.add( + responses.POST, + f"{endpoint}/processes/{product_dataset}/execution", + status=200, + content_type="application/json", + body=f'{{"status": "accepted", "jobID": "{product_id}"}}'.encode("utf-8"), + auto_calculate_content_length=True, + ) + responses.add( + responses.GET, + f"{endpoint}/jobs/{product_id}", + status=200, + content_type="application/json", + body=f'{{"status": "successful", "jobID": "{product_id}"}}'.encode("utf-8"), + auto_calculate_content_length=True, + ) + responses.add( + responses.GET, + f"{endpoint}/jobs/{product_id}/results", + status=200, + content_type="application/json", + body=(f'{{"asset": {{"value": {{"href": "http://somewhere/download/{product_id}"}} }} }}'.encode("utf-8")), + auto_calculate_content_length=True, + ) + + await request_valid( + url=url, + method="POST", + post_data=post_data, + search_result=SearchResult([product]), + expected_search_kwargs=expected_search_kwargs, + ) + + await run() + + +async def test_order_validate_with_errors(app, app_client, mocker, settings_cache_clear): + """Order a product through eodag server with invalid parameters must return informative error message""" + get_settings().validate_request = True + collection_id = "AG_ERA5" + errors = [ + ("wekeo_ecmwf", ValidationError("2 error(s). ecmwf:version: Field required; ecmwf:variable: Field required")), + ("cop_cds", ValidationError("2 error(s). ecmwf:version: Field required; ecmwf:variable: Field required")), + ] + expected_response = { + "code": "400", + "description": "Something went wrong", + "errors": [ + { + "provider": "wekeo_ecmwf", + "error": "ValidationError", + "status_code": 400, + "message": "2 error(s). ecmwf:version: Field required; ecmwf:variable: Field required", + }, + { + "provider": "cop_cds", + "error": "ValidationError", + "status_code": 400, + "message": "2 error(s). ecmwf:version: Field required; ecmwf:variable: Field required", + }, + ], + } + + mock_search = mocker.patch.object(app.state.dag, "search") + mock_search.return_value = SearchResult([], 0, errors) + + response = await app_client.request( + "POST", + f"/collections/{collection_id}/order", + json=None, + follow_redirects=True, + headers={}, + ) + response_content = response.json() + + assert response.status_code == 400 + assert "ticket" in response_content + response_content.pop("ticket", None) + assert expected_response == response_content diff --git a/tests/test_search.py b/tests/test_search.py index 70f23fb..7b8ab47 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -19,7 +19,9 @@ import pytest from eodag.api.product.metadata_mapping import ONLINE_STATUS +from eodag.api.search_result import SearchResult from eodag.utils import format_dict_items +from eodag.utils.exceptions import ValidationError from stac_fastapi.eodag.config import get_settings from stac_fastapi.eodag.constants import DEFAULT_ITEMS_PER_PAGE @@ -49,6 +51,7 @@ async def test_request_params_valid(request_valid, defaults, input_bbox, expecte items_per_page=DEFAULT_ITEMS_PER_PAGE, raise_errors=False, count=False, + validate=True, **expected_kwargs, ), ) @@ -70,6 +73,7 @@ async def test_count_search(request_valid, defaults, mock_search, mock_search_re items_per_page=DEFAULT_ITEMS_PER_PAGE, raise_errors=False, count=False, # Ensure count is set to False + validate=True, ), ) assert response["numberMatched"] is None @@ -89,6 +93,7 @@ async def test_count_search(request_valid, defaults, mock_search, mock_search_re items_per_page=DEFAULT_ITEMS_PER_PAGE, raise_errors=False, count=True, # Ensure count is set to True + validate=True, ), ) assert response["numberMatched"] == 2 @@ -251,6 +256,7 @@ async def test_date_search(request_valid, defaults, input_start, input_end, expe geom=defaults.bbox_wkt, raise_errors=False, count=False, + validate=True, **expected_kwargs, ), ) @@ -271,6 +277,7 @@ async def test_date_search_from_items(request_valid, defaults, use_dates): geom=defaults.bbox_wkt, raise_errors=False, count=False, + validate=True, **expected_kwargs, ), ) @@ -298,6 +305,7 @@ async def test_sortby_items_parametrize(request_valid, defaults, sortby, expecte "items_per_page": 10, "raise_errors": False, "count": False, + "validate": True, }, check_links=False, ) @@ -321,6 +329,7 @@ async def test_search_item_id_from_collection(request_valid, defaults): expected_search_kwargs={ "id": "foo", "productType": defaults.product_type, + "validate": True, }, ) @@ -343,6 +352,7 @@ async def test_cloud_cover_post_search(request_valid, defaults): geom=defaults.bbox_wkt, raise_errors=False, count=False, + validate=True, ), ) @@ -363,6 +373,7 @@ async def test_intersects_post_search(request_valid, defaults): geom=defaults.bbox_wkt, raise_errors=False, count=False, + validate=True, ), ) @@ -397,6 +408,7 @@ async def test_date_post_search(request_valid, defaults, input_start, input_end, items_per_page=DEFAULT_ITEMS_PER_PAGE, raise_errors=False, count=False, + validate=True, **expected_kwargs, ), ) @@ -416,10 +428,12 @@ async def test_ids_post_search(request_valid, defaults): { "id": "foo", "productType": defaults.product_type, + "validate": True, }, { "id": "bar", "productType": defaults.product_type, + "validate": True, }, ], ) @@ -523,6 +537,7 @@ async def test_search_provider_in_downloadlink(request_valid, defaults, method, raise_errors=False, count=False, productType=defaults.product_type, + validate=True, **expected_kwargs, ), ) @@ -530,3 +545,70 @@ async def test_search_provider_in_downloadlink(request_valid, defaults, method, assert all( [i["assets"]["downloadLink"]["href"] for i in response_items if i["properties"]["order:status"] != "orderable"] ) + + +@pytest.mark.parametrize("validate", [True, False]) +async def test_search_validate(request_valid, defaults, settings_cache_clear, validate): + """ + Search through eodag server must be validated according to settings + """ + get_settings().validate_request = validate + + expected_kwargs = {"validate": validate} + + await request_valid( + f"search?collections={defaults.product_type}", + expected_search_kwargs=dict( + productType=defaults.product_type, + page=1, + items_per_page=DEFAULT_ITEMS_PER_PAGE, + raise_errors=False, + count=False, + **expected_kwargs, + ), + ) + + +async def test_search_validate_with_errors(app, app_client, mocker, settings_cache_clear): + """Search through eodag server must display provider's error if validation fails""" + get_settings().validate_request = True + collection_id = "AG_ERA5" + errors = [ + ("wekeo_ecmwf", ValidationError("2 error(s). ecmwf:version: Field required; ecmwf:variable: Field required")), + ("cop_cds", ValidationError("2 error(s). ecmwf:version: Field required; ecmwf:variable: Field required")), + ] + expected_response = { + "code": "400", + "description": "Something went wrong", + "errors": [ + { + "provider": "wekeo_ecmwf", + "error": "ValidationError", + "status_code": 400, + "message": "2 error(s). ecmwf:version: Field required; ecmwf:variable: Field required", + }, + { + "provider": "cop_cds", + "error": "ValidationError", + "status_code": 400, + "message": "2 error(s). ecmwf:version: Field required; ecmwf:variable: Field required", + }, + ], + } + + mock_search = mocker.patch.object(app.state.dag, "search") + mock_search.return_value = SearchResult([], 0, errors) + + response = await app_client.request( + "GET", + f"search?collections={collection_id}", + json=None, + follow_redirects=True, + headers={}, + ) + response_content = response.json() + + assert response.status_code == 400 + assert "ticket" in response_content + response_content.pop("ticket", None) + assert expected_response == response_content