From ec5537ee072ce97dd3c7191ca9700bed3d46bcbb Mon Sep 17 00:00:00 2001 From: Nicola Dalpasso Date: Fri, 19 Sep 2025 12:06:57 +0200 Subject: [PATCH 01/11] feat: add support to request validation --- stac_fastapi/eodag/app.py | 3 ++ stac_fastapi/eodag/core.py | 16 +++++++++-- stac_fastapi/eodag/extensions/stac.py | 41 ++++++++++++++++++++++++++- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/stac_fastapi/eodag/app.py b/stac_fastapi/eodag/app.py index 30bcf40..3b2d478 100644 --- a/stac_fastapi/eodag/app.py +++ b/stac_fastapi/eodag/app.py @@ -72,6 +72,7 @@ ScientificCitationExtension, StorageExtension, TimestampExtension, + ValidateExtension, ViewGeometryExtension, ) from stac_fastapi.eodag.logs import RequestIDMiddleware, init_logging @@ -108,6 +109,7 @@ "sort": SortExtension(), "filter": FilterExtension(client=FiltersClient(stac_metadata_model=stac_metadata_model)), "pagination": PaginationExtension(), + "validate": ValidateExtension(), } # collection_search extensions @@ -122,6 +124,7 @@ itm_col_extensions_map = { "pagination": PaginationExtension(), "sort": SortExtension(conformance_classes=[SortConformanceClasses.ITEMS]), + "validate": ValidateExtension(), } all_extensions = { diff --git a/stac_fastapi/eodag/core.py b/stac_fastapi/eodag/core.py index c3a4c6b..4229296 100644 --- a/stac_fastapi/eodag/core.py +++ b/stac_fastapi/eodag/core.py @@ -363,6 +363,7 @@ async def item_collection( limit: Optional[int] = None, page: Optional[str] = None, sortby: Optional[list[str]] = None, + validate: Optional[bool] = None, **kwargs: Any, ) -> ItemCollection: """ @@ -376,6 +377,8 @@ async def item_collection( :param datetime: Date and time range to filter the items. :param limit: Maximum number of items to return. :param page: Page token for pagination. + :param validate: Set to True to validate the request query before sending it + to the provider :param kwargs: Additional arguments. :returns: An ItemCollection. :raises NotFoundError: If the collection does not exist. @@ -395,6 +398,10 @@ async def item_collection( sortby_converted = get_sortby_to_post(sortby) base_args["sortby"] = cast(Any, sortby_converted) + if validate is not None: + # The model uses `validate_request` + base_args["validate_request"] = validate + clean = {} for k, v in base_args.items(): if v is not None and v != []: @@ -552,7 +559,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 = ( { @@ -606,6 +613,11 @@ def prepare_search_base_args(search_request: BaseSearchPostRequest, model: type[ parsed_filter = parse_cql2(f) eodag_filter = {model.to_eodag(k): v for k, v in parsed_filter.items()} + validate = {} + if getattr(search_request, "validate_request", None) is not None: + # Converts back from `validate_request` used in the model and `validate` used by EODAG API + validate["validate"] = search_request.validate_request + # EODAG search support a single collection if search_request.collections: base_args["productType"] = search_request.collections[0] @@ -614,7 +626,7 @@ def prepare_search_base_args(search_request: BaseSearchPostRequest, model: type[ base_args["ids"] = search_request.ids # merge all eodag search arguments - base_args = base_args | sort_by | eodag_filter | eodag_query + base_args = base_args | sort_by | eodag_filter | eodag_query | validate return base_args diff --git a/stac_fastapi/eodag/extensions/stac.py b/stac_fastapi/eodag/extensions/stac.py index e142b89..fb83cef 100644 --- a/stac_fastapi/eodag/extensions/stac.py +++ b/stac_fastapi/eodag/extensions/stac.py @@ -17,16 +17,19 @@ # limitations under the License. """properties for extensions.""" -from typing import Annotated, Any, Optional, Union +from typing import Annotated, Any, List, Optional, Union import attr from eodag.api.product.metadata_mapping import ONLINE_STATUS +from fastapi import FastAPI, Query from pydantic import ( BaseModel, BeforeValidator, Field, field_validator, ) +from stac_fastapi.types.extension import ApiExtension +from stac_fastapi.types.search import APIRequest from stac_fastapi.eodag.utils import str2liststr @@ -292,3 +295,39 @@ class FederationExtension(BaseStacExtension): schema_href: str = attr.ib(default="https://api.openeo.org/extensions/federation/0.1.0") field_name_prefix: Optional[str] = attr.ib(default="federation") + + +class POSTValidate(BaseModel): + """Validate for POST requests.""" + + # Cannot use `validate` to avoid shadowing attribute in parent class `BaseModel` + validate_request: Optional[bool] = None + + +@attr.s +class GETValidate(APIRequest): + """Validate for GET requests.""" + + validate: Annotated[Optional[bool], Query(description="Validate the request")] = attr.ib(default=None) + + +@attr.s +class ValidateExtension(ApiExtension): + """Validate request extension.""" + + GET = GETValidate + POST = POSTValidate + + conformance_classes: List[str] = attr.ib(factory=list) + schema_href: Optional[str] = attr.ib(default=None) + + def register(self, app: FastAPI) -> None: + """Register the extension with a FastAPI application. + + Args: + app: target FastAPI application. + + Returns: + None + """ + pass From 8df126723167068bbda5568a862021e09ec7c6b4 Mon Sep 17 00:00:00 2001 From: Nicola Dalpasso Date: Fri, 19 Sep 2025 14:35:42 +0200 Subject: [PATCH 02/11] fix: use param `validate_request` in GET and POST requests --- stac_fastapi/eodag/app.py | 2 +- stac_fastapi/eodag/core.py | 19 ++++--- stac_fastapi/eodag/extensions/stac.py | 41 +------------- stac_fastapi/eodag/extensions/validate.py | 65 +++++++++++++++++++++++ 4 files changed, 79 insertions(+), 48 deletions(-) create mode 100644 stac_fastapi/eodag/extensions/validate.py diff --git a/stac_fastapi/eodag/app.py b/stac_fastapi/eodag/app.py index 3b2d478..2b187d1 100644 --- a/stac_fastapi/eodag/app.py +++ b/stac_fastapi/eodag/app.py @@ -72,9 +72,9 @@ ScientificCitationExtension, StorageExtension, TimestampExtension, - ValidateExtension, ViewGeometryExtension, ) +from stac_fastapi.eodag.extensions.validate import ValidateExtension from stac_fastapi.eodag.logs import RequestIDMiddleware, init_logging from stac_fastapi.eodag.middlewares import ProxyHeaderMiddleware from stac_fastapi.eodag.models.stac_metadata import create_stac_metadata_model diff --git a/stac_fastapi/eodag/core.py b/stac_fastapi/eodag/core.py index 4229296..01e2bc7 100644 --- a/stac_fastapi/eodag/core.py +++ b/stac_fastapi/eodag/core.py @@ -363,7 +363,7 @@ async def item_collection( limit: Optional[int] = None, page: Optional[str] = None, sortby: Optional[list[str]] = None, - validate: Optional[bool] = None, + validate_request: Optional[bool] = None, **kwargs: Any, ) -> ItemCollection: """ @@ -377,8 +377,8 @@ async def item_collection( :param datetime: Date and time range to filter the items. :param limit: Maximum number of items to return. :param page: Page token for pagination. - :param validate: Set to True to validate the request query before sending it - to the provider + :param validate_request: Set to True to validate the request query before sending it + to the provider :param kwargs: Additional arguments. :returns: An ItemCollection. :raises NotFoundError: If the collection does not exist. @@ -398,9 +398,8 @@ async def item_collection( sortby_converted = get_sortby_to_post(sortby) base_args["sortby"] = cast(Any, sortby_converted) - if validate is not None: - # The model uses `validate_request` - base_args["validate_request"] = validate + if validate_request is not None: + base_args["validate_request"] = validate_request clean = {} for k, v in base_args.items(): @@ -441,6 +440,7 @@ def get_search( intersects: Optional[str] = None, filter_expr: Optional[str] = None, filter_lang: Optional[str] = "cql2-text", + validate_request: Optional[bool] = None, **kwargs: Any, ) -> ItemCollection: """ @@ -458,6 +458,8 @@ def get_search( :param intersects: GeoJSON geometry to filter the search. :param filter_expr: CQL filter to apply to the search. :param filter_lang: Language of the filter (default is "cql2-text"). + :param validate_request: Set to True to validate the request query before sending it + to the provider :param kwargs: Additional arguments. :returns: Found items. :raises HTTPException: If the provided parameters are invalid. @@ -484,6 +486,9 @@ def get_search( base_args["filter"] = str2json("filter_expr", filter_expr) base_args["filter_lang"] = "cql2-json" + if validate_request is not None: + base_args["validate_request"] = validate_request + # Remove None values from dict clean = {} for k, v in base_args.items(): @@ -615,7 +620,7 @@ def prepare_search_base_args(search_request: BaseSearchPostRequest, model: type[ validate = {} if getattr(search_request, "validate_request", None) is not None: - # Converts back from `validate_request` used in the model and `validate` used by EODAG API + # Converts back from `validate_request` used in the model to `validate` used by EODAG API validate["validate"] = search_request.validate_request # EODAG search support a single collection diff --git a/stac_fastapi/eodag/extensions/stac.py b/stac_fastapi/eodag/extensions/stac.py index fb83cef..e142b89 100644 --- a/stac_fastapi/eodag/extensions/stac.py +++ b/stac_fastapi/eodag/extensions/stac.py @@ -17,19 +17,16 @@ # limitations under the License. """properties for extensions.""" -from typing import Annotated, Any, List, Optional, Union +from typing import Annotated, Any, Optional, Union import attr from eodag.api.product.metadata_mapping import ONLINE_STATUS -from fastapi import FastAPI, Query from pydantic import ( BaseModel, BeforeValidator, Field, field_validator, ) -from stac_fastapi.types.extension import ApiExtension -from stac_fastapi.types.search import APIRequest from stac_fastapi.eodag.utils import str2liststr @@ -295,39 +292,3 @@ class FederationExtension(BaseStacExtension): schema_href: str = attr.ib(default="https://api.openeo.org/extensions/federation/0.1.0") field_name_prefix: Optional[str] = attr.ib(default="federation") - - -class POSTValidate(BaseModel): - """Validate for POST requests.""" - - # Cannot use `validate` to avoid shadowing attribute in parent class `BaseModel` - validate_request: Optional[bool] = None - - -@attr.s -class GETValidate(APIRequest): - """Validate for GET requests.""" - - validate: Annotated[Optional[bool], Query(description="Validate the request")] = attr.ib(default=None) - - -@attr.s -class ValidateExtension(ApiExtension): - """Validate request extension.""" - - GET = GETValidate - POST = POSTValidate - - conformance_classes: List[str] = attr.ib(factory=list) - schema_href: Optional[str] = attr.ib(default=None) - - def register(self, app: FastAPI) -> None: - """Register the extension with a FastAPI application. - - Args: - app: target FastAPI application. - - Returns: - None - """ - pass diff --git a/stac_fastapi/eodag/extensions/validate.py b/stac_fastapi/eodag/extensions/validate.py new file mode 100644 index 0000000..0def45a --- /dev/null +++ b/stac_fastapi/eodag/extensions/validate.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# Copyright 2025, CS GROUP - France, https://www.cs-soprasteria.com +# +# This file is part of stac-fastapi-eodag project +# https://www.github.com/CS-SI/stac-fastapi-eodag +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Request validation extension.""" + +from typing import Annotated, List, Optional + +import attr +from fastapi import FastAPI, Query +from pydantic import ( + BaseModel, + Field, +) +from stac_fastapi.types.extension import ApiExtension +from stac_fastapi.types.search import APIRequest + + +class POSTValidate(BaseModel): + """Validate for POST requests.""" + + # Cannot use `validate` to avoid shadowing attribute in parent class `BaseModel` + validate_request: Optional[bool] = Field(None, description="Validate the request") # noqa: E501 + + +@attr.s +class GETValidate(APIRequest): + """Validate for GET requests.""" + + validate_request: Annotated[Optional[bool], Query(description="Validate the request")] = attr.ib(default=None) + + +@attr.s +class ValidateExtension(ApiExtension): + """Validate request extension.""" + + GET = GETValidate + POST = POSTValidate + + conformance_classes: List[str] = attr.ib(factory=list) + schema_href: Optional[str] = attr.ib(default=None) + + def register(self, app: FastAPI) -> None: + """Register the extension with a FastAPI application. + + Args: + app: target FastAPI application. + + Returns: + None + """ + pass From 8a0d2163f808b78f0414b5b2353dafe4549f79f5 Mon Sep 17 00:00:00 2001 From: Nicola Dalpasso Date: Mon, 22 Sep 2025 18:23:17 +0200 Subject: [PATCH 03/11] feat: don't validate endpoint `/collections/{collection_id}/items` --- stac_fastapi/eodag/core.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/stac_fastapi/eodag/core.py b/stac_fastapi/eodag/core.py index 01e2bc7..d8f6a11 100644 --- a/stac_fastapi/eodag/core.py +++ b/stac_fastapi/eodag/core.py @@ -363,7 +363,6 @@ async def item_collection( limit: Optional[int] = None, page: Optional[str] = None, sortby: Optional[list[str]] = None, - validate_request: Optional[bool] = None, **kwargs: Any, ) -> ItemCollection: """ @@ -377,8 +376,6 @@ async def item_collection( :param datetime: Date and time range to filter the items. :param limit: Maximum number of items to return. :param page: Page token for pagination. - :param validate_request: Set to True to validate the request query before sending it - to the provider :param kwargs: Additional arguments. :returns: An ItemCollection. :raises NotFoundError: If the collection does not exist. @@ -398,9 +395,6 @@ async def item_collection( sortby_converted = get_sortby_to_post(sortby) base_args["sortby"] = cast(Any, sortby_converted) - if validate_request is not None: - base_args["validate_request"] = validate_request - clean = {} for k, v in base_args.items(): if v is not None and v != []: From 87b352a35d0662a91a528d6a18e03ff5d4548eb4 Mon Sep 17 00:00:00 2001 From: Nicola Dalpasso Date: Tue, 23 Sep 2025 12:20:20 +0200 Subject: [PATCH 04/11] feat: use alias `validate` in the search request model --- stac_fastapi/eodag/core.py | 7 ++----- stac_fastapi/eodag/extensions/validate.py | 8 ++++++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/stac_fastapi/eodag/core.py b/stac_fastapi/eodag/core.py index d8f6a11..50fa058 100644 --- a/stac_fastapi/eodag/core.py +++ b/stac_fastapi/eodag/core.py @@ -481,7 +481,7 @@ def get_search( base_args["filter_lang"] = "cql2-json" if validate_request is not None: - base_args["validate_request"] = validate_request + base_args["validate"] = validate_request # Remove None values from dict clean = {} @@ -612,10 +612,7 @@ def prepare_search_base_args(search_request: BaseSearchPostRequest, model: type[ parsed_filter = parse_cql2(f) eodag_filter = {model.to_eodag(k): v for k, v in parsed_filter.items()} - validate = {} - if getattr(search_request, "validate_request", None) is not None: - # Converts back from `validate_request` used in the model to `validate` used by EODAG API - validate["validate"] = search_request.validate_request + validate = search_request.model_dump(exclude_none=True, by_alias=True, include={"validate_request"}) # EODAG search support a single collection if search_request.collections: diff --git a/stac_fastapi/eodag/extensions/validate.py b/stac_fastapi/eodag/extensions/validate.py index 0def45a..2275e6f 100644 --- a/stac_fastapi/eodag/extensions/validate.py +++ b/stac_fastapi/eodag/extensions/validate.py @@ -23,6 +23,7 @@ from fastapi import FastAPI, Query from pydantic import ( BaseModel, + ConfigDict, Field, ) from stac_fastapi.types.extension import ApiExtension @@ -33,14 +34,17 @@ class POSTValidate(BaseModel): """Validate for POST requests.""" # Cannot use `validate` to avoid shadowing attribute in parent class `BaseModel` - validate_request: Optional[bool] = Field(None, description="Validate the request") # noqa: E501 + validate_request: Optional[bool] = Field(default=None, alias="validate", description="Validate the request") # noqa: E501 + model_config = ConfigDict(serialize_by_alias=True, validate_by_name=False, validate_by_alias=True) @attr.s class GETValidate(APIRequest): """Validate for GET requests.""" - validate_request: Annotated[Optional[bool], Query(description="Validate the request")] = attr.ib(default=None) + validate_request: Annotated[Optional[bool], Query(alias="validate", description="Validate the request")] = attr.ib( + default=None + ) @attr.s From cdf0366886392d19fd1a6b1f983bd5f8792f3ca2 Mon Sep 17 00:00:00 2001 From: Nicola Dalpasso Date: Tue, 23 Sep 2025 17:09:28 +0200 Subject: [PATCH 05/11] feat: adopt validation in the endpoint `/search` --- stac_fastapi/eodag/models/links.py | 7 ++- stac_fastapi/eodag/models/stac_metadata.py | 2 + tests/test_search.py | 60 ++++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/stac_fastapi/eodag/models/links.py b/stac_fastapi/eodag/models/links.py index ded714f..7c9e085 100644 --- a/stac_fastapi/eodag/models/links.py +++ b/stac_fastapi/eodag/models/links.py @@ -315,10 +315,15 @@ class ItemLinks(CollectionLinksBase): def link_self(self) -> dict[str, str]: """Create the self link.""" + query_string = "" + validate = self.retrieve_body.get("validate", None) + if validate is not None: + query_string = f"?validate={validate}" + return { "rel": Relations.self.value, "type": MimeTypes.geojson.value, - "href": self.resolve(f"collections/{self.collection_id}/items/{self.item_id}"), + "href": self.resolve(f"collections/{self.collection_id}/items/{self.item_id}{query_string}"), "title": "Original item link", } diff --git a/stac_fastapi/eodag/models/stac_metadata.py b/stac_fastapi/eodag/models/stac_metadata.py index d2792cd..6d86f91 100644 --- a/stac_fastapi/eodag/models/stac_metadata.py +++ b/stac_fastapi/eodag/models/stac_metadata.py @@ -365,6 +365,8 @@ def create_stac_item( if eodag_args := getattr(request.state, "eodag_args", None): if provider := eodag_args.get("provider", None): retrieve_body["federation:backends"] = [provider] + if "validate" in eodag_args: + retrieve_body["validate"] = eodag_args["validate"] feature["links"] = ItemLinks( collection_id=collection, diff --git a/tests/test_search.py b/tests/test_search.py index 70f23fb..f534d24 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -17,6 +17,9 @@ # limitations under the License. """Search tests.""" +from typing import Any +from urllib.parse import parse_qs, urlparse + import pytest from eodag.api.product.metadata_mapping import ONLINE_STATUS from eodag.utils import format_dict_items @@ -530,3 +533,60 @@ 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("input_validate,expected_validate", [(None, None), ("true", True), ("false", False)]) +async def test_search_validate(request_valid, defaults, input_validate, expected_validate): + """ + Test request validation for the search endpoint. + """ + input_qs = f"&validate={input_validate}" if input_validate else "" + expected_kwargs = {"validate": expected_validate} if expected_validate is not None else {} + + await request_valid( + f"search?collections={defaults.product_type}{input_qs}", + expected_search_kwargs=dict( + productType=defaults.product_type, + page=1, + items_per_page=DEFAULT_ITEMS_PER_PAGE, + raise_errors=False, + count=False, + **expected_kwargs, + ), + ) + + +@pytest.mark.parametrize("input_validate", [None, "true", "false"]) +async def test_search_response_contains_validate_parameter(request_valid, defaults, input_validate): + """Responses to valid search requests must propagate parameter `validate` in the links""" + + def assert_validate(expected_value: str, params: dict[str, Any]): + if expected_value: + assert "validate" in params + actual_value: str + # if `params` comes from a query string, a list of values is given for each key + if isinstance(params["validate"], list): + actual_value = params["validate"][0] + else: + actual_value = params["validate"] + assert actual_value.lower() == expected_value.lower() + else: + assert "validate" not in params + + params: dict[str, Any] + input_qs = f"&validate={input_validate}" if input_validate else "" + response = await request_valid(f"search?collections={defaults.product_type}{input_qs}") + + # next page link + link_next: str = next(link for link in response["links"] if link["rel"] == "next")["href"] + params = parse_qs(urlparse(link_next).query) + assert_validate(input_validate, params) + + # feature's links + for feature in response["features"]: + for link in feature["links"]: + if link["rel"] == "retrieve": + assert_validate(input_validate, link["body"]) + elif link["rel"] == "self": + params = parse_qs(urlparse(link["href"]).query) + assert_validate(input_validate, params) From 6b035260d6fe61d071202571f74a4ece0610fe38 Mon Sep 17 00:00:00 2001 From: Nicola Dalpasso Date: Mon, 29 Sep 2025 13:50:34 +0200 Subject: [PATCH 06/11] fix: request validation enabled with configuration Validation is enabled only via configuration and it cannot being changed in the user request. Remove previous validation extension. --- stac_fastapi/eodag/app.py | 3 - stac_fastapi/eodag/config.py | 5 ++ stac_fastapi/eodag/core.py | 18 +++--- stac_fastapi/eodag/extensions/validate.py | 69 ---------------------- stac_fastapi/eodag/models/links.py | 7 +-- stac_fastapi/eodag/models/stac_metadata.py | 2 - tests/test_search.py | 50 ++-------------- 7 files changed, 19 insertions(+), 135 deletions(-) delete mode 100644 stac_fastapi/eodag/extensions/validate.py diff --git a/stac_fastapi/eodag/app.py b/stac_fastapi/eodag/app.py index 2b187d1..30bcf40 100644 --- a/stac_fastapi/eodag/app.py +++ b/stac_fastapi/eodag/app.py @@ -74,7 +74,6 @@ TimestampExtension, ViewGeometryExtension, ) -from stac_fastapi.eodag.extensions.validate import ValidateExtension from stac_fastapi.eodag.logs import RequestIDMiddleware, init_logging from stac_fastapi.eodag.middlewares import ProxyHeaderMiddleware from stac_fastapi.eodag.models.stac_metadata import create_stac_metadata_model @@ -109,7 +108,6 @@ "sort": SortExtension(), "filter": FilterExtension(client=FiltersClient(stac_metadata_model=stac_metadata_model)), "pagination": PaginationExtension(), - "validate": ValidateExtension(), } # collection_search extensions @@ -124,7 +122,6 @@ itm_col_extensions_map = { "pagination": PaginationExtension(), "sort": SortExtension(conformance_classes=[SortConformanceClasses.ITEMS]), - "validate": ValidateExtension(), } all_extensions = { diff --git a/stac_fastapi/eodag/config.py b/stac_fastapi/eodag/config.py index 5880969..2a05464 100644 --- a/stac_fastapi/eodag/config.py +++ b/stac_fastapi/eodag/config.py @@ -65,6 +65,11 @@ class Settings(ApiSettings): validate_default=False, ) + validate: bool = Field( + default=True, + description="Validate search and product order requests", + ) + @lru_cache(maxsize=1) def get_settings() -> Settings: diff --git a/stac_fastapi/eodag/core.py b/stac_fastapi/eodag/core.py index 50fa058..9fb4432 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 + # 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) @@ -434,7 +438,6 @@ def get_search( intersects: Optional[str] = None, filter_expr: Optional[str] = None, filter_lang: Optional[str] = "cql2-text", - validate_request: Optional[bool] = None, **kwargs: Any, ) -> ItemCollection: """ @@ -452,8 +455,6 @@ def get_search( :param intersects: GeoJSON geometry to filter the search. :param filter_expr: CQL filter to apply to the search. :param filter_lang: Language of the filter (default is "cql2-text"). - :param validate_request: Set to True to validate the request query before sending it - to the provider :param kwargs: Additional arguments. :returns: Found items. :raises HTTPException: If the provided parameters are invalid. @@ -480,9 +481,6 @@ def get_search( base_args["filter"] = str2json("filter_expr", filter_expr) base_args["filter_lang"] = "cql2-json" - if validate_request is not None: - base_args["validate"] = validate_request - # Remove None values from dict clean = {} for k, v in base_args.items(): @@ -612,8 +610,6 @@ def prepare_search_base_args(search_request: BaseSearchPostRequest, model: type[ parsed_filter = parse_cql2(f) eodag_filter = {model.to_eodag(k): v for k, v in parsed_filter.items()} - validate = search_request.model_dump(exclude_none=True, by_alias=True, include={"validate_request"}) - # EODAG search support a single collection if search_request.collections: base_args["productType"] = search_request.collections[0] @@ -622,7 +618,7 @@ def prepare_search_base_args(search_request: BaseSearchPostRequest, model: type[ base_args["ids"] = search_request.ids # merge all eodag search arguments - base_args = base_args | sort_by | eodag_filter | eodag_query | validate + base_args = base_args | sort_by | eodag_filter | eodag_query return base_args diff --git a/stac_fastapi/eodag/extensions/validate.py b/stac_fastapi/eodag/extensions/validate.py deleted file mode 100644 index 2275e6f..0000000 --- a/stac_fastapi/eodag/extensions/validate.py +++ /dev/null @@ -1,69 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2025, CS GROUP - France, https://www.cs-soprasteria.com -# -# This file is part of stac-fastapi-eodag project -# https://www.github.com/CS-SI/stac-fastapi-eodag -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -"""Request validation extension.""" - -from typing import Annotated, List, Optional - -import attr -from fastapi import FastAPI, Query -from pydantic import ( - BaseModel, - ConfigDict, - Field, -) -from stac_fastapi.types.extension import ApiExtension -from stac_fastapi.types.search import APIRequest - - -class POSTValidate(BaseModel): - """Validate for POST requests.""" - - # Cannot use `validate` to avoid shadowing attribute in parent class `BaseModel` - validate_request: Optional[bool] = Field(default=None, alias="validate", description="Validate the request") # noqa: E501 - model_config = ConfigDict(serialize_by_alias=True, validate_by_name=False, validate_by_alias=True) - - -@attr.s -class GETValidate(APIRequest): - """Validate for GET requests.""" - - validate_request: Annotated[Optional[bool], Query(alias="validate", description="Validate the request")] = attr.ib( - default=None - ) - - -@attr.s -class ValidateExtension(ApiExtension): - """Validate request extension.""" - - GET = GETValidate - POST = POSTValidate - - conformance_classes: List[str] = attr.ib(factory=list) - schema_href: Optional[str] = attr.ib(default=None) - - def register(self, app: FastAPI) -> None: - """Register the extension with a FastAPI application. - - Args: - app: target FastAPI application. - - Returns: - None - """ - pass diff --git a/stac_fastapi/eodag/models/links.py b/stac_fastapi/eodag/models/links.py index 7c9e085..ded714f 100644 --- a/stac_fastapi/eodag/models/links.py +++ b/stac_fastapi/eodag/models/links.py @@ -315,15 +315,10 @@ class ItemLinks(CollectionLinksBase): def link_self(self) -> dict[str, str]: """Create the self link.""" - query_string = "" - validate = self.retrieve_body.get("validate", None) - if validate is not None: - query_string = f"?validate={validate}" - return { "rel": Relations.self.value, "type": MimeTypes.geojson.value, - "href": self.resolve(f"collections/{self.collection_id}/items/{self.item_id}{query_string}"), + "href": self.resolve(f"collections/{self.collection_id}/items/{self.item_id}"), "title": "Original item link", } diff --git a/stac_fastapi/eodag/models/stac_metadata.py b/stac_fastapi/eodag/models/stac_metadata.py index 6d86f91..d2792cd 100644 --- a/stac_fastapi/eodag/models/stac_metadata.py +++ b/stac_fastapi/eodag/models/stac_metadata.py @@ -365,8 +365,6 @@ def create_stac_item( if eodag_args := getattr(request.state, "eodag_args", None): if provider := eodag_args.get("provider", None): retrieve_body["federation:backends"] = [provider] - if "validate" in eodag_args: - retrieve_body["validate"] = eodag_args["validate"] feature["links"] = ItemLinks( collection_id=collection, diff --git a/tests/test_search.py b/tests/test_search.py index f534d24..850a0c6 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -17,9 +17,6 @@ # limitations under the License. """Search tests.""" -from typing import Any -from urllib.parse import parse_qs, urlparse - import pytest from eodag.api.product.metadata_mapping import ONLINE_STATUS from eodag.utils import format_dict_items @@ -535,16 +532,17 @@ async def test_search_provider_in_downloadlink(request_valid, defaults, method, ) -@pytest.mark.parametrize("input_validate,expected_validate", [(None, None), ("true", True), ("false", False)]) -async def test_search_validate(request_valid, defaults, input_validate, expected_validate): +@pytest.mark.parametrize("validate", [True, False]) +async def test_search_validate(request_valid, defaults, validate): """ Test request validation for the search endpoint. """ - input_qs = f"&validate={input_validate}" if input_validate else "" - expected_kwargs = {"validate": expected_validate} if expected_validate is not None else {} + get_settings().validate = validate + + expected_kwargs = {"validate": validate} await request_valid( - f"search?collections={defaults.product_type}{input_qs}", + f"search?collections={defaults.product_type}", expected_search_kwargs=dict( productType=defaults.product_type, page=1, @@ -554,39 +552,3 @@ async def test_search_validate(request_valid, defaults, input_validate, expected **expected_kwargs, ), ) - - -@pytest.mark.parametrize("input_validate", [None, "true", "false"]) -async def test_search_response_contains_validate_parameter(request_valid, defaults, input_validate): - """Responses to valid search requests must propagate parameter `validate` in the links""" - - def assert_validate(expected_value: str, params: dict[str, Any]): - if expected_value: - assert "validate" in params - actual_value: str - # if `params` comes from a query string, a list of values is given for each key - if isinstance(params["validate"], list): - actual_value = params["validate"][0] - else: - actual_value = params["validate"] - assert actual_value.lower() == expected_value.lower() - else: - assert "validate" not in params - - params: dict[str, Any] - input_qs = f"&validate={input_validate}" if input_validate else "" - response = await request_valid(f"search?collections={defaults.product_type}{input_qs}") - - # next page link - link_next: str = next(link for link in response["links"] if link["rel"] == "next")["href"] - params = parse_qs(urlparse(link_next).query) - assert_validate(input_validate, params) - - # feature's links - for feature in response["features"]: - for link in feature["links"]: - if link["rel"] == "retrieve": - assert_validate(input_validate, link["body"]) - elif link["rel"] == "self": - params = parse_qs(urlparse(link["href"]).query) - assert_validate(input_validate, params) From 55344a2bad949176a5496d0cf0e83bbaf6dab5cf Mon Sep 17 00:00:00 2001 From: Nicola Dalpasso Date: Tue, 30 Sep 2025 15:47:23 +0200 Subject: [PATCH 07/11] feat: validate product order --- .../eodag/extensions/collection_order.py | 11 ++- tests/test_order.py | 80 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/stac_fastapi/eodag/extensions/collection_order.py b/stac_fastapi/eodag/extensions/collection_order.py index 199d17d..e17118a 100644 --- a/stac_fastapi/eodag/extensions/collection_order.py +++ b/stac_fastapi/eodag/extensions/collection_order.py @@ -36,6 +36,7 @@ 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.models.stac_metadata import ( CommonStacMetadata, create_stac_item, @@ -86,7 +87,15 @@ 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 + 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]) diff --git a/tests/test_order.py b/tests/test_order.py index f4d502d..8ce89f6 100644 --- a/tests/test_order.py +++ b/tests/test_order.py @@ -28,6 +28,8 @@ from eodag.plugins.download.base import Download from eodag.plugins.manager import PluginManager +from stac_fastapi.eodag.config import get_settings + @pytest.mark.parametrize("post_data", [{"foo": "bar"}, {}]) async def test_order_ok(request_valid, post_data): @@ -334,3 +336,81 @@ 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, validate): + """Test product order validation""" + get_settings().validate = 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() From e9fb394ff3e3e1e462b6a643d002ae7a985c2545 Mon Sep 17 00:00:00 2001 From: Nicola Dalpasso Date: Wed, 1 Oct 2025 09:54:40 +0200 Subject: [PATCH 08/11] fix: rename setting `validate` to `validate_request` This fix prevents shadowing an attribute in the parent class. --- stac_fastapi/eodag/config.py | 3 ++- stac_fastapi/eodag/core.py | 2 +- stac_fastapi/eodag/extensions/collection_order.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/stac_fastapi/eodag/config.py b/stac_fastapi/eodag/config.py index 2a05464..121b12b 100644 --- a/stac_fastapi/eodag/config.py +++ b/stac_fastapi/eodag/config.py @@ -65,9 +65,10 @@ class Settings(ApiSettings): validate_default=False, ) - validate: bool = Field( + validate_request: bool = Field( default=True, description="Validate search and product order requests", + alias="validate", ) diff --git a/stac_fastapi/eodag/core.py b/stac_fastapi/eodag/core.py index 9fb4432..3fb4ca9 100644 --- a/stac_fastapi/eodag/core.py +++ b/stac_fastapi/eodag/core.py @@ -162,7 +162,7 @@ def _search_base(self, search_request: BaseSearchPostRequest, request: Request) # validate request settings = get_settings() - validate: bool = settings.validate + validate: bool = settings.validate_request # check if the collection exists if product_type := eodag_args.get("productType"): diff --git a/stac_fastapi/eodag/extensions/collection_order.py b/stac_fastapi/eodag/extensions/collection_order.py index e17118a..87e2c87 100644 --- a/stac_fastapi/eodag/extensions/collection_order.py +++ b/stac_fastapi/eodag/extensions/collection_order.py @@ -89,7 +89,7 @@ def order_collection( request_params = request_body.model_dump(exclude={"federation_backends": True}) settings = get_settings() - validate: bool = settings.validate + validate: bool = settings.validate_request search_results = dag.search( productType=collection_id, provider=federation_backend, From 0491f7f21f40a2992144bd0ff700a3aa7503172a Mon Sep 17 00:00:00 2001 From: Nicola Dalpasso Date: Tue, 30 Sep 2025 16:31:18 +0200 Subject: [PATCH 09/11] fix(test): adapt tests to new search parimeter `validate` --- tests/test_order.py | 6 ++++-- tests/test_search.py | 17 +++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/test_order.py b/tests/test_order.py index 8ce89f6..ce36f26 100644 --- a/tests/test_order.py +++ b/tests/test_order.py @@ -98,6 +98,7 @@ async def run(): expected_search_kwargs=dict( productType=collection_id, provider=None, + validate=True, **post_data, ), ) @@ -188,6 +189,7 @@ async def run(): expected_search_kwargs=dict( productType=collection_id, provider=None, + validate=True, **post_data, ), ) @@ -339,9 +341,9 @@ async def test_order_not_order_id_ko(request_not_found, mock_search, mock_order) @pytest.mark.parametrize("validate", [True, False]) -async def test_order_validate(request_valid, validate): +async def test_order_validate(request_valid, settings_cache_clear, validate): """Test product order validation""" - get_settings().validate = validate + get_settings().validate_request = validate post_data = {"foo": "bar"} federation_backend = "cop_ads" collection_id = "CAMS_EAC4" diff --git a/tests/test_search.py b/tests/test_search.py index 850a0c6..413a6a1 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -49,6 +49,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 +71,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 +91,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 +254,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 +275,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 +303,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 +327,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 +350,7 @@ async def test_cloud_cover_post_search(request_valid, defaults): geom=defaults.bbox_wkt, raise_errors=False, count=False, + validate=True, ), ) @@ -363,6 +371,7 @@ async def test_intersects_post_search(request_valid, defaults): geom=defaults.bbox_wkt, raise_errors=False, count=False, + validate=True, ), ) @@ -397,6 +406,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 +426,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 +535,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, ), ) @@ -533,11 +546,11 @@ async def test_search_provider_in_downloadlink(request_valid, defaults, method, @pytest.mark.parametrize("validate", [True, False]) -async def test_search_validate(request_valid, defaults, validate): +async def test_search_validate(request_valid, defaults, settings_cache_clear, validate): """ Test request validation for the search endpoint. """ - get_settings().validate = validate + get_settings().validate_request = validate expected_kwargs = {"validate": validate} From fff48d9be7914d78c2bb5e957e5bebc7e9046d93 Mon Sep 17 00:00:00 2001 From: Nicola Dalpasso Date: Wed, 1 Oct 2025 15:39:07 +0200 Subject: [PATCH 10/11] feat: response with informative message in case of errors --- stac_fastapi/eodag/extensions/collection_order.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/stac_fastapi/eodag/extensions/collection_order.py b/stac_fastapi/eodag/extensions/collection_order.py index 87e2c87..f69889d 100644 --- a/stac_fastapi/eodag/extensions/collection_order.py +++ b/stac_fastapi/eodag/extensions/collection_order.py @@ -37,6 +37,7 @@ 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, @@ -99,7 +100,8 @@ def order_collection( 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}.", From 7fe30ea58d20a7cab8114a2ddf251637e2fe8a08 Mon Sep 17 00:00:00 2001 From: Nicola Dalpasso Date: Wed, 1 Oct 2025 16:16:20 +0200 Subject: [PATCH 11/11] test: add test for failed request validation --- tests/test_order.py | 48 ++++++++++++++++++++++++++++++++++++++++++- tests/test_search.py | 49 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/tests/test_order.py b/tests/test_order.py index ce36f26..e433e88 100644 --- a/tests/test_order.py +++ b/tests/test_order.py @@ -27,6 +27,7 @@ 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 @@ -342,7 +343,7 @@ async def test_order_not_order_id_ko(request_not_found, mock_search, mock_order) @pytest.mark.parametrize("validate", [True, False]) async def test_order_validate(request_valid, settings_cache_clear, validate): - """Test product order validation""" + """Product order through eodag server must be validated according to settings""" get_settings().validate_request = validate post_data = {"foo": "bar"} federation_backend = "cop_ads" @@ -416,3 +417,48 @@ async def run(): ) 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 413a6a1..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 @@ -548,7 +550,7 @@ async def test_search_provider_in_downloadlink(request_valid, defaults, method, @pytest.mark.parametrize("validate", [True, False]) async def test_search_validate(request_valid, defaults, settings_cache_clear, validate): """ - Test request validation for the search endpoint. + Search through eodag server must be validated according to settings """ get_settings().validate_request = validate @@ -565,3 +567,48 @@ async def test_search_validate(request_valid, defaults, settings_cache_clear, va **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