Repository: CS-SI/eodag
Pull request #974 (2024-01-08 10:41:23)

fix: lib and server mode queryables
Fixes and refactor of `list_queryables()` and server-mode `/queryables` features.

`list_queryables()` returns a dict containing the EODAG queryable properties, `Tuple[Annotated, Any]]`, tuple of annotated type and default value, that can be directly used as `pydantic.create_model()` argument:
```py
>>> from pydantic import create_model
>>> from eodag import EODataAccessGateway
>>> dag = EODataAccessGateway()
>>> queryables = dag.list_queryables(product_type="S2_MSI_L1C", provider="peps")
>>> queryables
{'productType': (typing.Annotated[str, FieldInfo(annotation=NoneType, required=True)], None), ...}

>>> my_model = create_model("my_model", **queryables)
>>> my_model(productType="S2_MSI_L1C", start="2023-01-01", end="2023-01-05", cloudCover=20)
my_model(productType="S2_MSI_L1C", start="2023-01-01", end="2023-01-05", cloudCover=20, id=None, ...)

>>> my_model(productType="S2_MSI_L1C", cloudCover=150)
pydantic_core._pydantic_core.ValidationError: 1 validation error for my_model
cloudCover
  Input should be less than 100 [type=less_than, input_value=150, input_type=int]
```

github-actions[bot]: ## Test Results
  2 files   -     2    2 suites   - 2   1m 35s :stopwatch: - 2m 32s
447 tests +    6  441 :white_check_mark: +  3   3 :zzz: ± 0  3 :x: +3 
728 runs   - 1 036  689 :white_check_mark:  - 997  36 :zzz:  - 42  3 :x: +3 

For more details on these failures, see [this check](https://github.com/CS-SI/eodag/runs/20256383955).

Results for commit dd9a8526. ± Comparison against base commit 9b952243.

<details>
  <summary>This pull request <b>removes</b> 1 and <b>adds</b> 7 tests. <i>Note that renamed tests count towards both.</i></summary>

```
tests.units.test_core.TestCore ‑ test_get_queryables
```

```
eodag.types.__init__ ‑ eodag.types.json_field_definition_to_python
eodag.types.__init__ ‑ eodag.types.json_type_to_python
eodag.types.__init__ ‑ eodag.types.model_fields_to_annotated_tuple
eodag.types.__init__ ‑ eodag.types.python_field_definition_to_json
eodag.types.__init__ ‑ eodag.types.python_type_to_json
eodag.types.queryables ‑ eodag.types.queryables.CommonQueryables.get_queryable_from_alias
tests.units.test_core.TestCore ‑ test_list_queryables
```
</details>

[test-results]:data:application/gzip;base64,H4sIAPjRm2UC/1XMyQqDMBSF4VeRrLuIN3NfpqQZINShZFhJ371XUWuX/3fgLCSmIRRy7+DWkdJSPcO3bGuaJ0wjsHGp68a5OupRmnMb9T96pTcSOyHaNPxByHnOKBQlt2n9VKD3OC6lNqfsj/KAy+PW10M3j2OqGMR7Y7UACaJXWgoatX8G5QJE3nvKwFAWBQRNPl+ePbtjBQEAAA==

github-actions[bot]: <strong>Code Coverage (Ubuntu)</strong>

| File | Coverage |   |
| - | :-: | :-: |
| **All files** | `81%` | :white_check_mark: |

_Minimum allowed coverage is `70%`_

<p align="right">Generated by :monkey: cobertura-action against 9d030a63336c663907302cc3315e779e9b1b1996 </p>
github-actions[bot]: <strong>Code Coverage (Windows)</strong>

| File | Coverage |   |
| - | :-: | :-: |
| **All files** | `76%` | :white_check_mark: |

_Minimum allowed coverage is `70%`_

<p align="right">Generated by :monkey: cobertura-action against 9d030a63336c663907302cc3315e779e9b1b1996 </p>

Patch:
diff --git a/docs/api_reference/core.rst b/docs/api_reference/core.rst
index 2d4681c4..106fe39d 100644
--- a/docs/api_reference/core.rst
+++ b/docs/api_reference/core.rst
@@ -86,4 +86,4 @@ Misc
    :members: set_preferred_provider, get_preferred_provider, update_providers_config, list_product_types,
              available_providers, search, search_all, search_iter_page, crunch, download, download_all, serialize,
              deserialize, deserialize_and_register, load_stac_items, group_by_extent, guess_product_type, get_cruncher,
-             update_product_types_list, fetch_product_types_list, discover_product_types
+             update_product_types_list, fetch_product_types_list, discover_product_types, list_queryables
diff --git a/docs/api_reference/index.rst b/docs/api_reference/index.rst
index 23163af0..945d9134 100644
--- a/docs/api_reference/index.rst
+++ b/docs/api_reference/index.rst
@@ -14,4 +14,5 @@ The Python API Reference provides an overview of all public objects, functions a
    eoproduct
    utils
    exceptions
+   types
    call_graphs
diff --git a/docs/api_reference/types.rst b/docs/api_reference/types.rst
new file mode 100644
index 00000000..0850b0ac
--- /dev/null
+++ b/docs/api_reference/types.rst
@@ -0,0 +1,6 @@
+=====
+Types
+=====
+
+.. automodule:: eodag.types.__init__
+   :members:
diff --git a/docs/conf.py b/docs/conf.py
index 657510ba..bf5ca4a4 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -17,11 +17,7 @@
 # add these directories to sys.path here. If the directory is relative to the
 # documentation root, use os.path.abspath to make it absolute, like shown here.
 #
-try:
-    from importlib.metadata import metadata  # type: ignore
-except ImportError:  # pragma: no cover
-    # for python < 3.8
-    from importlib_metadata import metadata  # type: ignore
+from importlib.metadata import metadata
 from typing import Dict
 
 # -- General configuration ------------------------------------------------
diff --git a/eodag/__init__.py b/eodag/__init__.py
index 25207f66..cba8e2b1 100644
--- a/eodag/__init__.py
+++ b/eodag/__init__.py
@@ -23,18 +23,13 @@
 # exactly as below)
 """EODAG package"""
 import warnings
+from importlib.metadata import PackageNotFoundError, version
 
 from .api.core import EODataAccessGateway  # noqa
 from .api.product import EOProduct  # noqa
 from .api.search_result import SearchResult  # noqa
 from .utils.logging import setup_logging  # noqa
 
-try:
-    from importlib.metadata import PackageNotFoundError, version  # type: ignore
-except ImportError:  # pragma: no cover
-    # for python < 3.8
-    from importlib_metadata import PackageNotFoundError, version  # type: ignore
-
 try:
     __version__ = version(__name__)
 except PackageNotFoundError:  # pragma: no cover
diff --git a/eodag/api/core.py b/eodag/api/core.py
index e138cd03..d0c0485d 100644
--- a/eodag/api/core.py
+++ b/eodag/api/core.py
@@ -26,7 +26,6 @@ from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Tuple, Un
 
 import geojson
 import pkg_resources
-import requests
 import yaml.parser
 from pkg_resources import resource_filename
 from whoosh import analysis, fields
@@ -34,12 +33,9 @@ from whoosh.fields import Schema
 from whoosh.index import create_in, exists_in, open_dir
 from whoosh.qparser import QueryParser
 
-from eodag.api.product.metadata_mapping import mtd_cfg_as_conversion_and_querypath
-from eodag.api.queryables import (
-    BaseQueryableProperty,
-    Queryables,
-    format_provider_queryables,
-    format_queryable,
+from eodag.api.product.metadata_mapping import (
+    NOT_MAPPED,
+    mtd_cfg_as_conversion_and_querypath,
 )
 from eodag.api.search_result import SearchResult
 from eodag.config import (
@@ -55,6 +51,8 @@ from eodag.config import (
 )
 from eodag.plugins.manager import PluginManager
 from eodag.plugins.search.build_search_result import BuildPostSearchResult
+from eodag.types import model_fields_to_annotated_tuple
+from eodag.types.queryables import CommonQueryables, Queryables
 from eodag.utils import (
     DEFAULT_DOWNLOAD_TIMEOUT,
     DEFAULT_DOWNLOAD_WAIT,
@@ -63,10 +61,10 @@ from eodag.utils import (
     DEFAULT_PAGE,
     GENERIC_PRODUCT_TYPE,
     HTTP_REQ_TIMEOUT,
-    USER_AGENT,
     MockResponse,
     _deprecated,
     deepcopy,
+    get_args,
     get_geometry_from_various,
     makedirs,
     obj_md5sum,
@@ -92,7 +90,7 @@ if TYPE_CHECKING:
     from eodag.plugins.apis.base import Api
     from eodag.plugins.crunch.base import Crunch
     from eodag.plugins.search.base import Search
-    from eodag.utils import DownloadedCallback, ProgressCallback
+    from eodag.utils import Annotated, DownloadedCallback, ProgressCallback
 
 logger = logging.getLogger("eodag.core")
 
@@ -2085,205 +2083,104 @@ class EODataAccessGateway:
         self,
         provider: Optional[str] = None,
         product_type: Optional[str] = None,
-        base: Optional[bool] = True,
-    ) -> dict[str, BaseQueryableProperty]:
+    ) -> Dict[str, Tuple[Annotated, Any]]:
         """Fetch the queryable properties for a given product type and/or provider.
 
-        :param product_type: (optional) The EODAG product type.
-        :type product_type: str
         :param provider: (optional) The provider.
         :type provider: str
-        :param base: if base queryable objects or extended queryable objects should be created
-        :type base: bool
-        :returns: A dict containing the EODAG queryable properties.
-        :rtype: dict
+        :param product_type: (optional) The EODAG product type.
+        :type product_type: str
+        :returns: A dict containing the EODAG queryable properties, associating
+                  parameters to a tuple containing their annotaded type and default
+                  value
+        :rtype: Dict[str, Tuple[Annotated, Any]]
         """
-        if base:
-            default_queryables = Queryables().get_base_properties()
-        else:
-            default_queryables = Queryables().properties
-        if provider is None and product_type is None:
-            return default_queryables
-
-        plugins = self._plugins_manager.get_search_plugins(product_type, provider)
-        provider_plugin = None
+        # unknown product type
+        if product_type is not None and product_type not in self.list_product_types(
+            fetch_providers=False
+        ):
+            self.fetch_product_types_list()
 
         # dictionary of the queryable properties of the providers supporting the given product type
-        all_queryable_properties = dict()
-        for plugin in plugins:
-            if (
-                product_type
-                and product_type not in plugin.config.products.keys()
-                and provider is None
+        providers_available_queryables: Dict[
+            str, Dict[str, Tuple[Annotated, Any]]
+        ] = dict()
+
+        if provider is None and product_type is None:
+            return model_fields_to_annotated_tuple(CommonQueryables.model_fields)
+        elif provider is None:
+            for plugin in self._plugins_manager.get_search_plugins(
+                product_type, provider
             ):
-                raise UnsupportedProductType(product_type)
-            elif product_type and product_type not in plugin.config.products.keys():
-                raise UnsupportedProductType(
-                    f"{product_type} is not available for provider {provider}"
+                providers_available_queryables[plugin.provider] = self.list_queryables(
+                    provider=plugin.provider, product_type=product_type
                 )
 
-            provider_queryables = default_queryables
+            # return providers queryables intersection
+            queryables_keys = {}
+            for queryables in providers_available_queryables.values():
+                queryables_keys = (
+                    queryables_keys & queryables.keys()
+                    if queryables_keys
+                    else queryables.keys()
+                )
+            return {
+                k: v
+                for k, v in providers_available_queryables.popitem()[1].items()
+                if k in queryables_keys
+            }
 
-            metadata_mapping = deepcopy(getattr(plugin.config, "metadata_mapping", {}))
+        all_queryables = model_fields_to_annotated_tuple(Queryables.model_fields)
 
-            # product_type-specific metadata-mapping
-            metadata_mapping.update(
-                getattr(plugin.config, "products", {})
-                .get(product_type, {})
-                .get("metadata_mapping", {})
+        try:
+            plugin = next(
+                self._plugins_manager.get_search_plugins(product_type, provider)
             )
+        except StopIteration:
+            # return default queryables if no plugin is found
+            return model_fields_to_annotated_tuple(CommonQueryables.model_fields)
 
-            for key, value in metadata_mapping.items():
-                if (
-                    isinstance(value, list)
-                    and "TimeFromAscendingNode" not in key
-                    and key not in provider_queryables
-                ):
-                    queryable = format_queryable(key, base)
-                    provider_queryables[key] = queryable
+        providers_available_queryables[plugin.provider] = dict()
 
-            all_queryable_properties[plugin.provider] = provider_queryables
-            if provider and plugin.provider == provider:
-                provider_plugin = plugin
-                break
+        # unknown product type: try again after fetch_product_types_list()
+        if (
+            product_type
+            and product_type not in plugin.config.products.keys()
+            and provider is None
+        ):
+            raise UnsupportedProductType(product_type)
+        elif product_type and product_type not in plugin.config.products.keys():
+            raise UnsupportedProductType(
+                f"{product_type} is not available for provider {provider}"
+            )
 
-        if provider is None:
-            result = {}
-            for queryables in all_queryable_properties.values():
-                result.update(queryables)
-            return result
-        else:
-            if product_type:
-                provider_queryables = self._add_provider_product_type_queryables(
-                    provider_plugin,
-                    provider,
-                    product_type,
-                    all_queryable_properties[provider],
-                    base,
-                )
-            else:
-                provider_queryables = self._add_provider_queryables(
-                    provider_plugin, provider, all_queryable_properties[provider], base
-                )
-            return provider_queryables
+        metadata_mapping = deepcopy(getattr(plugin.config, "metadata_mapping", {}))
 
-    def _add_provider_queryables(
-        self,
-        search_plugin: Union[Search, Api],
-        provider: str,
-        queryables: dict,
-        base: bool,
-    ) -> Dict[str, Any]:
-        """Add the queryables fetched from the given provider to the default queryables.
-        If the queryables endpoint is not supported by the provider, the original queryables are returned
-        :param provider: The provider.
-        :type provider: str
-        :param queryables: default queryables to which provider queryables will be added
-        :type queryables: dict
-        :param base: if base queryable objects or extended queryable objects should be created
-        :type base: bool
-        :returns queryable_properties: A dict containing the formatted queryable properties
-                                       including queryables fetched from the provider.
-        :rtype dict
-        """
+        # product_type-specific metadata-mapping
+        metadata_mapping.update(
+            getattr(plugin.config, "products", {})
+            .get(product_type, {})
+            .get("metadata_mapping", {})
+        )
+        # remove not mapped parameters or non-queryables
+        for param in list(metadata_mapping.keys()):
+            if NOT_MAPPED in metadata_mapping[param] or not isinstance(
+                metadata_mapping[param], list
+            ):
+                del metadata_mapping[param]
 
-        search_type = search_plugin.config.type
-        if search_type == "StacSearch":
-            queryables_url = search_plugin.config.api_endpoint.replace(
-                "/search", "/queryables"
-            )
-        else:
-            queryables_url = ""
-        if not queryables_url:
-            logger.info(
-                "no url was found for %s provider-specific queryables", provider
-            )
-            return queryables
-        try:
-            headers = USER_AGENT
-            if hasattr(search_plugin, "auth"):
-                res = requests.get(
-                    queryables_url, headers=headers, auth=search_plugin.auth
-                )
-            else:
-                res = requests.get(queryables_url, headers=headers)
-            res.raise_for_status()
-        except requests.exceptions.HTTPError as err:
-            if err.response.status_code == 404:
-                logger.info("provider %s does not support queryables", provider)
-                return queryables
-            else:
-                raise RequestError(str(err))
-        else:
-            provider_queryables = res.json()["properties"]
-            return format_provider_queryables(provider_queryables, queryables, base)
+        for key, value in all_queryables.items():
+            annotated_args = get_args(value[0])
+            if len(annotated_args) < 1:
+                continue
+            field_info = annotated_args[1]
+            if field_info.is_required() or (
+                (field_info.alias or key) in metadata_mapping
+            ):
+                providers_available_queryables[plugin.provider][key] = value
 
-    def _add_provider_product_type_queryables(
-        self,
-        search_plugin: Union[Search, Api],
-        provider: str,
-        product_type: str,
-        queryables: dict,
-        base: bool,
-    ) -> Dict[str, Any]:
-        """Add the queryables fetched from the given provider for the given product type
-        to the default queryables derived from the metadata mapping.
-        If the queryables endpoint is not supported by the provider, the original queryables are returned.
-        :param provider: The provider.
-        :type provider: str
-        :param product_type: EODAG product type
-        :type product_type: str
-        :param queryables: default queryables to which provider queryables will be added
-        :type queryables: dict
-        :param base: if base queryable objects or extended queryable objects should be created
-        :type base: bool
-        :returns queryable_properties: A dict containing the formatted queryable properties
-                                       including queryables fetched from the provider.
-        :rtype dict
-        """
-        search_type = search_plugin.config.type
-        provider_product_type = search_plugin.config.products.get(product_type, {}).get(
-            "productType", None
-        )
-        if not provider_product_type:
-            logger.warning(
-                "provider product type mapping for product type %s not found",
-                product_type,
-            )
-            provider_product_type = product_type
-        if search_type == "StacSearch":
-            api_url = search_plugin.config.api_endpoint.replace("/search", "/")
-            queryables_url = (
-                api_url + "collections/" + provider_product_type + "/queryables"
-            )
-        else:
-            queryables_url = ""
+        provider_queryables = plugin.discover_queryables(product_type) or dict()
+        # use EODAG configured queryables by default
+        provider_queryables.update(providers_available_queryables[provider])
 
-        if not queryables_url:
-            logger.info(
-                "no url was found for %s on %s provider-specific queryables",
-                product_type,
-                provider,
-            )
-            return queryables
-        try:
-            headers = USER_AGENT
-            if hasattr(search_plugin, "auth"):
-                res = requests.get(
-                    queryables_url, headers=headers, auth=search_plugin.auth
-                )
-            else:
-                res = requests.get(queryables_url, headers=headers)
-            res.raise_for_status()
-        except requests.exceptions.HTTPError as err:
-            if err.response.status_code == 404:
-                logger.info("provider %s does not support queryables", provider)
-                return queryables
-            else:
-                raise RequestError(str(err))
-        else:
-            if "properties" in res.json():
-                provider_queryables = res.json()["properties"]
-                return format_provider_queryables(provider_queryables, queryables, base)
-            else:
-                return queryables
+        return provider_queryables
diff --git a/eodag/api/product/metadata_mapping.py b/eodag/api/product/metadata_mapping.py
index 2ffdec34..5b3b67ed 100644
--- a/eodag/api/product/metadata_mapping.py
+++ b/eodag/api/product/metadata_mapping.py
@@ -47,6 +47,7 @@ from shapely import wkt
 from shapely.geometry import MultiPolygon, Polygon
 from shapely.ops import transform
 
+from eodag.types.queryables import Queryables
 from eodag.utils import (
     DEFAULT_PROJ,
     deepcopy,
@@ -1354,6 +1355,43 @@ def _get_queryables(
     return queryables
 
 
+def get_queryable_from_provider(
+    provider_queryable: str, metadata_mapping: Dict[str, Union[str, List[str]]]
+) -> Optional[str]:
+    """Get EODAG configured queryable parameter from provider queryable parameter
+
+    :param provider_queryable: provider queryable parameter
+    :type provider_queryable: str
+    :param metadata_mapping: metadata-mapping configuration
+    :type metadata_mapping: Dict[str, Union[str, List[str]]])
+    :returns: EODAG configured queryable parameter or None
+    :rtype: Optional[str]
+    """
+    for param, param_conf in metadata_mapping.items():
+        if isinstance(param_conf, list) and provider_queryable in param_conf[0]:
+            return Queryables.get_queryable_from_alias(param)
+    return None
+
+
+def get_provider_queryable_path(
+    queryable: str, metadata_mapping: Dict[str, Union[str, List[str]]]
+) -> Optional[str]:
+    """Get EODAG configured queryable path from its parameter
+
+    :param queryable: eodag queryable parameter
+    :type queryable: str
+    :param metadata_mapping: metadata-mapping configuration
+    :type metadata_mapping: Dict[str, Union[str, List[str]]])
+    :returns: EODAG configured queryable path or None
+    :rtype: Optional[str]
+    """
+    parameter_conf = metadata_mapping.get(queryable, None)
+    if isinstance(parameter_conf, list):
+        return parameter_conf[0]
+    else:
+        return None
+
+
 # Keys taken from OpenSearch extension for Earth Observation http://docs.opengeospatial.org/is/13-026r9/13-026r9.html
 # For a metadata to be queryable, The way to query it must be specified in the
 # provider metadata_mapping configuration parameter. It will be automatically
diff --git a/eodag/api/queryables.py b/eodag/api/queryables.py
deleted file mode 100644
index c6608a2c..00000000
--- a/eodag/api/queryables.py
+++ /dev/null
@@ -1,211 +0,0 @@
-import re
-from typing import Any, Dict, Optional
-
-from pydantic import BaseModel, Field
-
-from eodag.api.product.metadata_mapping import OSEO_METADATA_MAPPING
-from eodag.config import load_stac_config
-
-CAMEL_TO_SPACE_TITLED = re.compile(r"[:_-]|(?<=[a-z])(?=[A-Z])")
-
-
-def rename_to_stac_standard(key: str) -> str:
-    """Fetch the queryable properties for a collection.
-
-    :param key: The camelCase key name obtained from a collection's metadata mapping.
-    :type key: str
-    :returns: The STAC-standardized property name if it exists, else the default camelCase queryable name
-    :rtype: str
-    """
-    # Load the stac config properties for renaming the properties
-    # to their STAC standard
-    stac_config = load_stac_config()
-    stac_config_properties: Dict[str, Any] = stac_config["item"]["properties"]
-
-    for stac_property, value in stac_config_properties.items():
-        if isinstance(value, list):
-            value = value[0]
-        if str(value).endswith(key):
-            return stac_property
-
-    if key in OSEO_METADATA_MAPPING:
-        return "oseo:" + key
-
-    return key
-
-
-class BaseQueryableProperty(BaseModel):
-    """A class representing a queryable property.
-
-    :param description: The description of the queryables property
-    :type description: str
-    :param type: (optional) possible types of the property
-    :type type: list[str]
-    """
-
-    description: str
-    type: Optional[list] = None
-
-    def update_properties(self, new_properties: dict):
-        """updates the properties with the given new properties keeping already existing value"""
-        if "type" in new_properties and not self.type:
-            self.type = new_properties["type"]
-
-
-class QueryableProperty(BaseQueryableProperty):
-    """A class representing a queryable property.
-
-    :param description: The description of the queryables property
-    :type description: str
-    :param ref: (optional) A reference link to the schema of the property.
-    :type ref: str
-    :param type: (optional) possible types of the property
-    :type type: list[str]
-    """
-
-    description: str
-    ref: Optional[str] = Field(default=None, serialization_alias="$ref")
-    type: Optional[list] = None
-
-    def update_properties(self, new_properties: dict):
-        """updates the properties with the given new properties keeping already existing value"""
-        super().update_properties(new_properties)
-        if "ref" in new_properties and not self.ref:
-            self.ref = new_properties["ref"]
-
-
-class Queryables(BaseModel):
-    """A class representing queryable properties for the STAC API.
-
-    :param json_schema: The URL of the JSON schema.
-    :type json_schema: str
-    :param q_id: (optional) The identifier of the queryables.
-    :type q_id: str
-    :param q_type: The type of the object.
-    :type q_type: str
-    :param title: The title of the queryables.
-    :type title: str
-    :param description: The description of the queryables
-    :type description: str
-    :param properties: A dictionary of queryable properties.
-    :type properties: dict
-    :param additional_properties: Whether additional properties are allowed.
-    :type additional_properties: bool
-    """
-
-    json_schema: str = Field(
-        default="https://json-schema.org/draft/2019-09/schema",
-        serialization_alias="$schema",
-    )
-    q_id: Optional[str] = Field(default=None, serialization_alias="$id")
-    q_type: str = Field(default="object", serialization_alias="type")
-    title: str = Field(default="Queryables for EODAG STAC API")
-    description: str = Field(
-        default="Queryable names for the EODAG STAC API Item Search filter."
-    )
-    properties: Dict[str, QueryableProperty] = Field(
-        default={
-            "id": QueryableProperty(
-                description="ID",
-                ref="https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/id",
-            ),
-            "collection": QueryableProperty(
-                description="Collection",
-                ref="https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/collection",
-            ),
-            "geometry": QueryableProperty(
-                description="Geometry",
-                ref="https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/geometry",
-            ),
-            "bbox": QueryableProperty(
-                description="Bbox",
-                ref="https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/bbox",
-            ),
-            "datetime": QueryableProperty(
-                description="Datetime",
-                ref="https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/datetime.json#/properties/datetime",
-            ),
-        }
-    )
-    additional_properties: bool = Field(
-        default=True, serialization_alias="additionalProperties"
-    )
-
-    def get_base_properties(self) -> Dict[str, BaseQueryableProperty]:
-        """Get the queryable properties.
-
-        :returns: A dictionary containing queryable properties.
-        :rtype: typing.Dict[str, QueryableProperty]
-        """
-        base_properties = {}
-        for key, property in self.properties.items():
-            base_property = BaseQueryableProperty(
-                description=property.description, type=property.type
-            )
-            base_properties[key] = base_property
-        return base_properties
-
-    def __contains__(self, name: str) -> bool:
-        return name in self.properties
-
-    def __setitem__(self, name: str, qprop: BaseQueryableProperty) -> None:
-        self.properties[name] = qprop
-
-
-def format_queryable(queryable_key: str, base=True) -> BaseQueryableProperty:
-    """
-    creates a queryable property from a property key
-    :param queryable_key: key of the property for which the queryable property shall be created
-    :type queryable_key: str
-    :param base: if a base queryable object or an extended queryable object should be created
-    :type base: bool
-    :returns: queryable property for the given key
-    :rtype: QueryableProperty
-    """
-    if queryable_key not in ["start", "end", "geom", "locations", "id"]:
-        stac_key = rename_to_stac_standard(queryable_key)
-    else:
-        stac_key = queryable_key
-    titled_name = re.sub(CAMEL_TO_SPACE_TITLED, " ", stac_key.split(":")[-1]).title()
-    if base:
-        return BaseQueryableProperty(description=titled_name)
-    return QueryableProperty(description=titled_name)
-
-
-def format_provider_queryables(
-    provider_queryables: dict, queryables: dict, base: bool = True
-) -> Dict[str, Any]:
-    """
-    formats the provider queryables and adds them to the existing queryables
-    :param provider_queryables: queryables fetched from the provider
-    :type provider_queryables: dict
-    :param queryables: default queryables to which provider queryables will be added
-    :type queryables: dict
-    :param base: if a base queryable object or an extended queryable object should be created
-    :type base: bool
-    :returns queryable_properties: A dict containing the formatted queryable properties
-                                       including queryables fetched from the provider.
-    :rtype dict
-    """
-    for queryable, data in provider_queryables.items():
-        titled_name = re.sub(
-            CAMEL_TO_SPACE_TITLED, " ", queryable.split(":")[-1]
-        ).title()
-        attributes = {"description": titled_name}
-        if queryable in queryables:
-            queryable_prop = queryables[queryable]
-        else:
-            if base:
-                queryable_prop = BaseQueryableProperty(description=titled_name)
-            else:
-                queryable_prop = QueryableProperty(description=titled_name)
-        if "type" in data:
-            if isinstance(data["type"], list):
-                attributes["type"] = data["type"]
-            else:
-                attributes["type"] = [data["type"]]
-        if "ref" in data and not base:
-            attributes["ref"] = data["ref"]
-        queryable_prop.update_properties(attributes)
-        queryables[queryable] = queryable_prop
-    return queryables
diff --git a/eodag/cli.py b/eodag/cli.py
index d18fc9f9..96dd9603 100755
--- a/eodag/cli.py
+++ b/eodag/cli.py
@@ -47,13 +47,7 @@ import os
 import shutil
 import sys
 import textwrap
-
-try:
-    from importlib.metadata import metadata  # type: ignore
-except ImportError:  # pragma: no cover
-    # for python < 3.8
-    from importlib_metadata import metadata  # type: ignore
-
+from importlib.metadata import metadata
 from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Set
 
 import click
diff --git a/eodag/plugins/apis/base.py b/eodag/plugins/apis/base.py
index 0675b13e..620806d9 100644
--- a/eodag/plugins/apis/base.py
+++ b/eodag/plugins/apis/base.py
@@ -18,13 +18,13 @@
 from __future__ import annotations
 
 import logging
-from typing import TYPE_CHECKING, Any, List, Optional, Tuple
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
 
 if TYPE_CHECKING:
     from eodag.api.product import EOProduct
     from eodag.api.search_result import SearchResult
     from eodag.config import PluginConfig
-    from eodag.utils import DownloadedCallback, ProgressCallback
+    from eodag.utils import DownloadedCallback, ProgressCallback, Annotated
 
 from eodag.plugins.base import PluginTopic
 from eodag.utils import (
@@ -89,6 +89,12 @@ class Api(PluginTopic):
         """
         raise NotImplementedError("A Api plugin must implement a method named query")
 
+    def discover_queryables(
+        self, product_type: Optional[str] = None
+    ) -> Optional[Dict[str, Tuple[Annotated, Any]]]:
+        """Fetch queryables list from provider using `discover_queryables` conf"""
+        return None
+
     def download(
         self,
         product: EOProduct,
diff --git a/eodag/plugins/search/base.py b/eodag/plugins/search/base.py
index ffe23eb8..02420873 100644
--- a/eodag/plugins/search/base.py
+++ b/eodag/plugins/search/base.py
@@ -35,7 +35,7 @@ from eodag.utils import (
 if TYPE_CHECKING:
     from eodag.api.product import EOProduct
     from eodag.config import PluginConfig
-
+    from eodag.utils import Annotated
 
 logger = logging.getLogger("eodag.search.base")
 
@@ -88,6 +88,12 @@ class Search(PluginTopic):
         """Fetch product types list from provider using `discover_product_types` conf"""
         return None
 
+    def discover_queryables(
+        self, product_type: Optional[str] = None
+    ) -> Optional[Dict[str, Tuple[Annotated, Any]]]:
+        """Fetch queryables list from provider using `discover_queryables` conf"""
+        return None
+
     def map_product_type(
         self, product_type: Optional[str], **kwargs: Any
     ) -> Optional[str]:
diff --git a/eodag/plugins/search/qssearch.py b/eodag/plugins/search/qssearch.py
index 62b54d7c..a4bd7c20 100644
--- a/eodag/plugins/search/qssearch.py
+++ b/eodag/plugins/search/qssearch.py
@@ -27,6 +27,8 @@ import orjson
 import requests
 import yaml
 from lxml import etree
+from pydantic import create_model
+from pydantic.fields import FieldInfo
 from requests import Response
 from requests.adapters import HTTPAdapter
 
@@ -34,17 +36,20 @@ from eodag.api.product import EOProduct
 from eodag.api.product.metadata_mapping import (
     NOT_AVAILABLE,
     format_query_params,
+    get_queryable_from_provider,
     mtd_cfg_as_conversion_and_querypath,
     properties_from_json,
     properties_from_xml,
 )
 from eodag.plugins.search.base import Search
+from eodag.types import json_field_definition_to_python, model_fields_to_annotated_tuple
 from eodag.utils import (
     DEFAULT_ITEMS_PER_PAGE,
     DEFAULT_PAGE,
     GENERIC_PRODUCT_TYPE,
     HTTP_REQ_TIMEOUT,
     USER_AGENT,
+    Annotated,
     _deprecated,
     deepcopy,
     dict_items_recursive_apply,
@@ -240,6 +245,16 @@ class QueryStringSearch(Search):
                 ]
             )
 
+        # parse jsonpath on init: queryables discovery
+        if (
+            getattr(self.config, "discover_queryables", {}).get("results_entry", None)
+            and getattr(self.config, "discover_queryables", {}).get("result_type", None)
+            == "json"
+        ):
+            self.config.discover_queryables["results_entry"] = string_to_jsonpath(
+                self.config.discover_queryables["results_entry"], force=True
+            )
+
         # parse jsonpath on init: product type specific metadata-mapping
         for product_type in self.config.products.keys():
             if "metadata_mapping" in self.config.products[product_type].keys():
@@ -1226,3 +1241,78 @@ class StacSearch(PostJsonSearch):
             product.assets.update(product.properties.pop("assets", {}))
 
         return products
+
+    def discover_queryables(
+        self, product_type: Optional[str] = None
+    ) -> Optional[Dict[str, Tuple[Annotated, Any]]]:
+        """Fetch queryables list from provider using `discover_queryables` conf
+
+        :param product_type: (optional) product type
+        :type product_type: str
+        :returns: fetched queryable parameters dict
+        :rtype: dict
+        """
+        provider_product_type = self.config.products.get(product_type, {}).get(
+            "productType", product_type
+        )
+
+        python_queryables: Dict[str, FieldInfo] = dict()
+
+        try:
+            unparsed_fetch_url = (
+                self.config.discover_queryables["product_type_fetch_url"]
+                if provider_product_type
+                else self.config.discover_queryables["fetch_url"]
+            )
+
+            fetch_url = unparsed_fetch_url.format(
+                provider_product_type=provider_product_type, **self.config.__dict__
+            )
+            response = QueryStringSearch._request(
+                self,
+                fetch_url,
+                info_message="Fetching queryables: {}".format(fetch_url),
+                exception_message="Skipping error while fetching queryables for "
+                "{} {} instance:".format(self.provider, self.__class__.__name__),
+            )
+        except (RequestError, KeyError, AttributeError):
+            return None
+        else:
+            json_queryables = dict()
+            try:
+                resp_as_json = response.json()
+
+                # extract results from response json
+                json_queryables = [
+                    match.value
+                    for match in self.config.discover_queryables["results_entry"].find(
+                        resp_as_json
+                    )
+                ][0]
+
+            except KeyError as e:
+                logger.warning(
+                    "Incomplete %s discover_queryables configuration: %s",
+                    self.provider,
+                    e,
+                )
+            except IndexError:
+                logger.info(
+                    "No queryable found for %s on %s", product_type, self.provider
+                )
+                return None
+
+            # convert json results to pydantic model fields
+            field_definitions = dict()
+            for json_param, json_mtd in json_queryables.items():
+                param = (
+                    get_queryable_from_provider(
+                        json_param, self.config.metadata_mapping
+                    )
+                    or json_param
+                )
+                field_definitions[param] = json_field_definition_to_python(json_mtd)
+
+            python_queryables = create_model("m", **field_definitions).model_fields
+
+        return model_fields_to_annotated_tuple(python_queryables)
diff --git a/eodag/resources/providers.yml b/eodag/resources/providers.yml
index 39da2a7a..78477217 100644
--- a/eodag/resources/providers.yml
+++ b/eodag/resources/providers.yml
@@ -1517,6 +1517,9 @@
     type: StacSearch
     api_endpoint: https://eod-catalog-svc-prod.astraea.earth/search
     need_auth: false
+    discover_queryables:
+      fetch_url: null
+      product_type_fetch_url: null
     pagination:
       # 2021/03/19: The docs (https://eod-catalog-svc-prod.astraea.earth/api.html#operation/getSearchSTAC)
       # say the max is 10_000. In practice 1_000 products are returned if more are asked (even greater
@@ -1634,6 +1637,9 @@
     type: StacSearch
     api_endpoint: https://landsatlook.usgs.gov/stac-server/search
     need_auth: false
+    discover_queryables:
+      fetch_url: null
+      product_type_fetch_url: null
     pagination:
       # 2021/03/19: no more than 10_000 (if greater, returns a 500 error code)
       # but in practive if an Internal Server Error is returned for more than
@@ -1680,6 +1686,9 @@
     need_auth: false
     discover_product_types:
       results_entry: '$.collections[?id!="sentinel-s2-l2a-cogs"]'
+    discover_queryables:
+      fetch_url: null
+      product_type_fetch_url: null
     pagination:
       # Override the default next page url key path of StacSearch because the next link returned
       # by Earth Search is invalid (as of 2021/04/29). Entry set to null (None) to avoid using the
@@ -1774,6 +1783,9 @@
     need_auth: false
     discover_product_types:
       fetch_url: null
+    discover_queryables:
+      fetch_url: null
+      product_type_fetch_url: null
     pagination:
       # Override the default next page url key path of StacSearch because the next link returned
       # by Earth Search is invalid (as of 2021/04/29). Entry set to null (None) to avoid using the
@@ -1809,6 +1821,9 @@
     need_auth: false
     discover_product_types:
       fetch_url: null
+    discover_queryables:
+      fetch_url: null
+      product_type_fetch_url: null
     pagination:
       # Override the default next page url key path of StacSearch because the next link returned
       # by Earth Search is invalid (as of 2021/04/29). Entry set to null (None) to avoid using the
@@ -3759,6 +3774,9 @@
     api_endpoint: https://hydroweb.next.theia-land.fr/api/v1/rs-catalog/stac/search
     need_auth: true
     auth_error_code: 401
+    discover_queryables:
+      fetch_url: null
+      product_type_fetch_url: null
     pagination:
       max_items_per_page: 10_000
     metadata_mapping:
diff --git a/eodag/resources/stac_provider.yml b/eodag/resources/stac_provider.yml
index 680b1d73..a6acbeff 100644
--- a/eodag/resources/stac_provider.yml
+++ b/eodag/resources/stac_provider.yml
@@ -30,22 +30,33 @@ search:
     search_param: '{{{{"query":{{{{"{metadata}":{{{{"eq":"{{{metadata}}}" }}}} }}}} }}}}'
     metadata_path: '$.properties.*'
   discover_product_types:
-      fetch_url: '{api_endpoint}/../collections'
-      result_type: json
-      results_entry: '$.collections[*]'
-      generic_product_type_id: '$.id'
-      generic_product_type_parsable_properties:
-        productType: '$.id'
-      generic_product_type_parsable_metadata:
-        abstract: '$.description'
-        instrument: '{$.summaries.instruments#csv_list}'
-        platform: '{$.summaries.constellation#csv_list}'
-        platformSerialIdentifier: '{$.summaries.platform#csv_list}'
-        processingLevel: '$.summaries."processing:level"'
-        keywords: '{$.keywords#csv_list}'
-        license: '$.license'
-        title: '$.title'
-        missionStartDate: '$.extent.temporal.interval[0][0]'
+    fetch_url: '{api_endpoint}/../collections'
+    result_type: json
+    results_entry: '$.collections[*]'
+    generic_product_type_id: '$.id'
+    generic_product_type_parsable_properties:
+      productType: '$.id'
+    generic_product_type_parsable_metadata:
+      abstract: '$.description'
+      instrument: '{$.summaries.instruments#csv_list}'
+      platform: '{$.summaries.constellation#csv_list}'
+      platformSerialIdentifier: '{$.summaries.platform#csv_list}'
+      processingLevel: '$.summaries."processing:level"'
+      keywords: '{$.keywords#csv_list}'
+      license: '$.license'
+      title: '$.title'
+      missionStartDate: '$.extent.temporal.interval[0][0]'
+    metadata_path: '$.properties.*'
+  discover_queryables:
+    fetch_url: '{api_endpoint}/../queryables'
+    product_type_fetch_url: '{api_endpoint}/../collections/{provider_product_type}/queryables'
+    result_type: json
+    results_entry: '$.properties[*]'
+    queryable_parsable_metadata:
+      description: '$.description'
+      title: '$.title'
+      type: '$.type'
+      pattern: '$.pattern'
   common_metadata_mapping_path: '$'
   metadata_mapping:
     # OpenSearch Parameters for Collection Search (Table 3)
diff --git a/eodag/rest/server.py b/eodag/rest/server.py
index aa2178d5..d079c867 100755
--- a/eodag/rest/server.py
+++ b/eodag/rest/server.py
@@ -45,8 +45,8 @@ from fastapi.responses import ORJSONResponse, StreamingResponse
 from pydantic import BaseModel
 from starlette.exceptions import HTTPException as StarletteHTTPException
 
-from eodag.api.queryables import Queryables
 from eodag.config import load_stac_api_config
+from eodag.rest.types.stac_queryables import StacQueryables
 from eodag.rest.utils import (
     download_stac_item_by_id_stream,
     eodag_api_init,
@@ -461,7 +461,7 @@ def list_collection_queryables(
     """
     logger.debug(f"URL: {request.url}")
 
-    queryables = Queryables(q_id=request.state.url, additional_properties=False)
+    queryables = StacQueryables(q_id=request.state.url, additional_properties=False)
 
     collection_queryables = fetch_collection_queryable_properties(
         collection_id, provider
@@ -674,7 +674,7 @@ def list_queryables(request: Request, provider: Optional[str] = None) -> Any:
     :rtype: Any
     """
     logger.debug(f"URL: {request.url}")
-    queryables = Queryables(q_id=request.state.url)
+    queryables = StacQueryables(q_id=request.state.url)
     if provider:
         queryables = fetch_collection_queryable_properties(None, provider)
 
diff --git a/eodag/rest/types/__init__.py b/eodag/rest/types/__init__.py
new file mode 100644
index 00000000..f0402eb8
--- /dev/null
+++ b/eodag/rest/types/__init__.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+# Copyright 2023, CS GROUP - France, https://www.csgroup.eu/
+#
+# This file is part of EODAG project
+#     https://www.github.com/CS-SI/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.
+"""EODAG rest.types package"""
diff --git a/eodag/rest/types/eodag_search.py b/eodag/rest/types/eodag_search.py
new file mode 100644
index 00000000..7828ca26
--- /dev/null
+++ b/eodag/rest/types/eodag_search.py
@@ -0,0 +1,188 @@
+# -*- coding: utf-8 -*-
+# Copyright 2023, CS GROUP - France, https://www.csgroup.eu/
+#
+# This file is part of EODAG project
+#     https://www.github.com/CS-SI/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.
+from typing import Any, Dict, List, Optional, Tuple, Union
+
+from pydantic import (
+    BaseModel,
+    ConfigDict,
+    Field,
+    ValidationInfo,
+    field_validator,
+    model_validator,
+)
+from shapely.geometry import (
+    GeometryCollection,
+    LinearRing,
+    LineString,
+    MultiLineString,
+    MultiPoint,
+    MultiPolygon,
+    Point,
+    Polygon,
+)
+
+from eodag.utils import DEFAULT_ITEMS_PER_PAGE
+
+Geometry = Union[
+    Dict[str, Any],
+    Point,
+    MultiPoint,
+    LineString,
+    MultiLineString,
+    Polygon,
+    MultiPolygon,
+    LinearRing,
+    GeometryCollection,
+]
+
+
+class EODAGSearch(BaseModel):
+    """Model used to convert a STAC formated request to an EODAG formated one"""
+
+    model_config = ConfigDict(
+        extra="allow", populate_by_name=True, arbitrary_types_allowed=True
+    )
+
+    productType: Optional[str] = Field(None, alias="collections", validate_default=True)
+    provider: Optional[str] = Field(None)
+    ids: Optional[List[str]] = Field(None)
+    geom: Optional[Geometry] = Field(None, alias="geometry")
+    start: Optional[str] = Field(None, alias="start_datetime")
+    end: Optional[str] = Field(None, alias="end_datetime")
+    publicationDate: Optional[str] = Field(None, alias="published")
+    creationDate: Optional[str] = Field(None, alias="created")
+    modificationDate: Optional[str] = Field(None, alias="updated")
+    platformSerialIdentifier: Optional[str] = Field(None, alias="platform")
+    instrument: Optional[str] = Field(None, alias="instruments")
+    platform: Optional[str] = Field(None, alias="constellation")
+    resolution: Optional[int] = Field(None, alias="gsd")
+    cloudCover: Optional[int] = Field(None, alias="eo:cloud_cover")
+    snowCover: Optional[int] = Field(None, alias="eo:snow_cover")
+    processingLevel: Optional[str] = Field(None, alias="processing:level")
+    orbitDirection: Optional[str] = Field(None, alias="sat:orbit_state")
+    relativeOrbitNumber: Optional[int] = Field(None, alias="sat:relative_orbit")
+    orbitNumber: Optional[int] = Field(None, alias="sat:absolute_orbit")
+    # TODO: colision in property name. Need to handle "sar:product_type"
+    sensorMode: Optional[str] = Field(None, alias="sar:instrument_mode")
+    polarizationChannels: Optional[List[str]] = Field(None, alias="sar:polarizations")
+    dopplerFrequency: Optional[str] = Field(None, alias="sar:frequency_band")
+    doi: Optional[str] = Field(None, alias="sci:doi")
+    productVersion: Optional[str] = Field(None, alias="version")
+    illuminationElevationAngle: Optional[float] = Field(
+        None, alias="view:sun_elevation"
+    )
+    illuminationAzimuthAngle: Optional[float] = Field(None, alias="view:sun_azimuth")
+    page: Optional[int] = Field(1)
+    items_per_page: int = Field(DEFAULT_ITEMS_PER_PAGE, alias="limit")
+    sortBy: Optional[List[Tuple[str, str]]] = Field(None, alias="sortby")
+
+    @model_validator(mode="before")
+    @classmethod
+    def remove_custom_extensions(cls, values: Dict[str, Any]) -> Dict[str, Any]:
+        """process unknown and oseo EODAG custom extensions fields"""
+        # Transform EODAG custom extensions OSEO and UNK.
+        keys_to_update: Dict[str, str] = {}
+        for key in values.keys():
+            if key.startswith("unk:"):
+                keys_to_update[key] = key[len("unk:") :]
+            elif key.startswith("oseo:"):
+                keys_to_update[key] = key[len("oseo:") :]
+
+        for old_key, new_key in keys_to_update.items():
+            values[cls.snake_to_camel(new_key)] = values.pop(old_key)
+
+        return values
+
+    @model_validator(mode="before")
+    @classmethod
+    def remove_keys(cls, values: Dict[str, Any]) -> Dict[str, Any]:
+        """Remove 'datetime', 'crunch', 'intersects', and 'bbox' keys"""
+        for key in ["datetime", "crunch", "intersects", "bbox"]:
+            values.pop(key, None)
+        return values
+
+    @field_validator("instrument", mode="before")
+    @classmethod
+    def join_instruments(cls, v: Union[str, List[str]]) -> str:
+        """convert instruments to instrument"""
+        if isinstance(v, list):
+            return ",".join(v)
+        return v
+
+    @field_validator("sortBy", mode="before")
+    @classmethod
+    def convert_stac_to_eodag_sortby(
+        cls,
+        sortby_post_params: List[Dict[str, str]],
+    ) -> List[Tuple[str, str]]:
+        """
+        Convert STAC POST sortby to EODAG sortby
+        """
+        eodag_sortby: List[Tuple[str, str]] = []
+        for sortby_post_param in sortby_post_params:
+            field = cls.snake_to_camel(cls.to_eodag(sortby_post_param["field"]))
+            eodag_sortby.append((field, sortby_post_param["direction"]))
+        return eodag_sortby
+
+    @field_validator("productType")
+    @classmethod
+    def verify_producttype_is_present(
+        cls, v: Optional[str], info: ValidationInfo
+    ) -> Optional[str]:
+        """Verify productType is present when required"""
+        if not v and (
+            not info
+            or not getattr(info, "context", None)
+            or not info.context.get("isCatalog")  # type: ignore
+        ):
+            raise ValueError("A collection is required")
+
+        return v
+
+    @field_validator("start", "end")
+    @classmethod
+    def cleanup_dates(cls, v: str) -> str:
+        """proper format dates"""
+        if v.endswith("+00:00"):
+            return v.replace("+00:00", "") + "Z"
+        return v
+
+    @classmethod
+    def snake_to_camel(cls, snake_str: str) -> str:
+        """Convert snake_case to camelCase"""
+        # Split the string by underscore and capitalize each component except the first one
+        components = snake_str.split("_")
+        return components[0] + "".join(x.title() for x in components[1:])
+
+    @classmethod
+    def to_eodag(cls, value: str) -> str:
+        """Convert a STAC parameter to its matching EODAG name"""
+        alias_map = {
+            field_info.alias: name
+            for name, field_info in cls.model_fields.items()
+            if field_info.alias
+        }
+        return alias_map.get(value, value)
+
+    @classmethod
+    def to_stac(cls, field_name: str) -> str:
+        """Get the alias of a field in a Pydantic model"""
+        field = cls.model_fields.get(field_name)
+        if field is not None and field.alias is not None:
+            return field.alias
+        return field_name
diff --git a/eodag/rest/types/stac_queryables.py b/eodag/rest/types/stac_queryables.py
new file mode 100644
index 00000000..6a4139fe
--- /dev/null
+++ b/eodag/rest/types/stac_queryables.py
@@ -0,0 +1,140 @@
+# -*- coding: utf-8 -*-
+# Copyright 2023, CS GROUP - France, https://www.csgroup.eu/
+#
+# This file is part of EODAG project
+#     https://www.github.com/CS-SI/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.
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional, Tuple, Union
+
+from pydantic import BaseModel, Field
+
+from eodag.types import python_field_definition_to_json
+from eodag.utils import Annotated
+
+
+class StacQueryableProperty(BaseModel):
+    """A class representing a queryable property.
+
+    :param description: The description of the queryables property
+    :type description: str
+    :param ref: (optional) A reference link to the schema of the property.
+    :type ref: str
+    :param type: (optional) possible types of the property
+    :type type: list[str]
+    """
+
+    description: str
+    ref: Optional[str] = Field(default=None, serialization_alias="$ref")
+    type: Optional[Union[str, List[str]]] = None
+
+    def update_properties(self, new_properties: dict):
+        """updates the properties with the given new properties keeping already existing value"""
+        if "type" in new_properties and not self.type:
+            self.type = new_properties["type"]
+        if "ref" in new_properties and not self.ref:
+            self.ref = new_properties["ref"]
+
+    @classmethod
+    def from_python_field_definition(
+        cls, id: str, python_field_definition: Tuple[Annotated, Any]
+    ) -> StacQueryableProperty:
+        """Build Model from python_field_definition"""
+        def_dict = python_field_definition_to_json(python_field_definition)
+
+        if not def_dict.get("description", None):
+            def_dict["description"] = def_dict.get("title", None) or id
+
+        return cls(**def_dict)
+
+
+class StacQueryables(BaseModel):
+    """A class representing queryable properties for the STAC API.
+
+    :param json_schema: The URL of the JSON schema.
+    :type json_schema: str
+    :param q_id: (optional) The identifier of the queryables.
+    :type q_id: str
+    :param q_type: The type of the object.
+    :type q_type: str
+    :param title: The title of the queryables.
+    :type title: str
+    :param description: The description of the queryables
+    :type description: str
+    :param properties: A dictionary of queryable properties.
+    :type properties: dict
+    :param additional_properties: Whether additional properties are allowed.
+    :type additional_properties: bool
+    """
+
+    json_schema: str = Field(
+        default="https://json-schema.org/draft/2019-09/schema",
+        serialization_alias="$schema",
+    )
+    q_id: Optional[str] = Field(default=None, serialization_alias="$id")
+    q_type: str = Field(default="object", serialization_alias="type")
+    title: str = Field(default="Queryables for EODAG STAC API")
+    description: str = Field(
+        default="Queryable names for the EODAG STAC API Item Search filter."
+    )
+    properties: Dict[str, StacQueryableProperty] = Field(
+        default={
+            "id": StacQueryableProperty(
+                description="ID",
+                ref="https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/id",
+            ),
+            "collection": StacQueryableProperty(
+                description="Collection",
+                ref="https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/collection",
+            ),
+            "geometry": StacQueryableProperty(
+                description="Geometry",
+                ref="https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/geometry",
+            ),
+            "bbox": StacQueryableProperty(
+                description="Bbox",
+                ref="https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/bbox",
+            ),
+            "datetime": StacQueryableProperty(
+                description="Datetime",
+                ref="https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/datetime.json#/properties/datetime",
+            ),
+        }
+    )
+    additional_properties: bool = Field(
+        default=True, serialization_alias="additionalProperties"
+    )
+
+    def get_properties(self) -> Dict[str, StacQueryableProperty]:
+        """Get the queryable properties.
+
+        :returns: A dictionary containing queryable properties.
+        :rtype: typing.Dict[str, StacQueryableProperty]
+        """
+        properties = {}
+        for key, property in self.properties.items():
+            property = StacQueryableProperty(
+                description=property.description, type=property.type
+            )
+            properties[key] = property
+        return properties
+
+    def __contains__(self, name: str) -> bool:
+        return name in self.properties
+
+    def __setitem__(self, name: str, qprop: StacQueryableProperty) -> None:
+        # only keep "datetime" queryable for dates
+        if name not in ("start_datetime", "end_datetime"):
+            self.properties[name] = qprop
diff --git a/eodag/rest/utils.py b/eodag/rest/utils.py
index 6d9a900d..b444529e 100644
--- a/eodag/rest/utils.py
+++ b/eodag/rest/utils.py
@@ -46,13 +46,14 @@ from shapely.geometry import Polygon, shape
 import eodag
 from eodag import EOProduct
 from eodag.api.product.metadata_mapping import OSEO_METADATA_MAPPING
-from eodag.api.queryables import BaseQueryableProperty
 from eodag.api.search_result import SearchResult
 from eodag.config import load_stac_config, load_stac_provider_config
 from eodag.plugins.crunch.filter_latest_intersect import FilterLatestIntersect
 from eodag.plugins.crunch.filter_latest_tpl_name import FilterLatestByName
 from eodag.plugins.crunch.filter_overlap import FilterOverlap
 from eodag.rest.stac import StacCatalog, StacCollection, StacCommon, StacItem
+from eodag.rest.types.eodag_search import EODAGSearch
+from eodag.rest.types.stac_queryables import StacQueryableProperty
 from eodag.utils import (
     DEFAULT_ITEMS_PER_PAGE,
     DEFAULT_PAGE,
@@ -1056,23 +1057,28 @@ def get_stac_extension_oseo(url: str) -> Dict[str, str]:
 
 def fetch_collection_queryable_properties(
     collection_id: Optional[str] = None, provider: Optional[str] = None
-) -> Dict[str, BaseQueryableProperty]:
+) -> Dict[str, StacQueryableProperty]:
     """Fetch the queryable properties for a collection.
 
     :param collection_id: The ID of the collection.
     :type collection_id: str
     :param provider: (optional) The provider.
     :type provider: str
-    :returns queryable_properties: A set containing the STAC standardized queryable properties for a collection.
-    :rtype queryable_properties: set
+    :returns: A set containing the STAC standardized queryable properties for a collection.
+    :rtype Dict[str, StacQueryableProperty]: set
     """
-    # Fetch the metadata mapping for collection-specific queryables
-    kwargs = {"base": False}
-    if collection_id is not None:
-        kwargs["product_type"] = collection_id
-    if provider is not None:
-        kwargs["provider"] = provider
-    return eodag_api.list_queryables(**kwargs)
+    python_queryables = eodag_api.list_queryables(
+        provider=provider, product_type=collection_id
+    )
+
+    stac_queryables = dict()
+    for param, queryable in python_queryables.items():
+        stac_param = EODAGSearch.to_stac(param)
+        stac_queryables[
+            stac_param
+        ] = StacQueryableProperty.from_python_field_definition(stac_param, queryable)
+
+    return stac_queryables
 
 
 def eodag_api_init() -> None:
diff --git a/eodag/types/__init__.py b/eodag/types/__init__.py
new file mode 100644
index 00000000..02b5f98f
--- /dev/null
+++ b/eodag/types/__init__.py
@@ -0,0 +1,220 @@
+# -*- coding: utf-8 -*-
+# Copyright 2023, CS GROUP - France, https://www.csgroup.eu/
+#
+# This file is part of EODAG project
+#     https://www.github.com/CS-SI/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.
+"""EODAG types"""
+from __future__ import annotations
+
+from typing import Any, Dict, List, Literal, Optional, Tuple, Union
+
+from pydantic import Field
+from pydantic.fields import FieldInfo
+
+from eodag.utils import Annotated, copy_deepcopy, get_args, get_origin
+from eodag.utils.exceptions import ValidationError
+
+# Types mapping from JSON Schema and OpenAPI 3.1.0 specifications to Python
+# See https://spec.openapis.org/oas/v3.1.0#data-types
+JSON_TYPES_MAPPING: Dict[str, type] = {
+    "boolean": bool,
+    "integer": int,
+    "number": float,
+    "string": str,
+    "array": list,
+    "null": type(None),
+}
+
+
+def json_type_to_python(json_type: Union[str, List[str]]) -> type:
+    """Get python type from json type https://spec.openapis.org/oas/v3.1.0#data-types
+
+    >>> json_type_to_python("number")
+    <class 'float'>
+
+    :param json_type: the json type
+    :returns: the python type
+    """
+    if isinstance(json_type, list) and len(json_type) > 1:
+        return Union[tuple(JSON_TYPES_MAPPING.get(jt, Any) for jt in json_type)]  # type: ignore
+    elif isinstance(json_type, str):
+        return JSON_TYPES_MAPPING.get(json_type, Any)
+    else:
+        return Any
+
+
+def python_type_to_json(python_type: type) -> Optional[Union[str, List[str]]]:
+    """Get json type from python https://spec.openapis.org/oas/v3.1.0#data-types
+
+    >>> python_type_to_json(int)
+    'integer'
+    >>> python_type_to_json(Union[float, str])
+    ['number', 'string']
+
+    :param python_type: the python type
+    :returns: the json type
+    """
+    if get_origin(python_type) is Union:
+        json_type = list()
+        for single_python_type in get_args(python_type):
+            if single_python_type in JSON_TYPES_MAPPING.values():
+                # JSON_TYPES_MAPPING key from given value
+                single_json_type = list(JSON_TYPES_MAPPING.keys())[
+                    list(JSON_TYPES_MAPPING.values()).index(single_python_type)
+                ]
+                json_type.append(single_json_type)
+        return json_type
+    elif python_type in JSON_TYPES_MAPPING.values():
+        # JSON_TYPES_MAPPING key from given value
+        return list(JSON_TYPES_MAPPING.keys())[
+            list(JSON_TYPES_MAPPING.values()).index(python_type)
+        ]
+    else:
+        return None
+
+
+def json_field_definition_to_python(
+    json_field_definition: Dict[str, Any], default_value: Optional[Any] = None
+) -> Annotated:
+    """Get python field definition from json object
+
+    >>> result = json_field_definition_to_python(
+    ...     {
+    ...         'type': 'boolean',
+    ...         'title': 'Foo parameter'
+    ...     }
+    ... )
+    >>> str(result).replace('_extensions', '') # python3.8 compatibility
+    "(typing.Annotated[bool, FieldInfo(annotation=NoneType, required=False, title='Foo parameter')], None)"
+
+    :param json_field_definition: the json field definition
+    :returns: the python field definition
+    """
+    python_type = json_type_to_python(json_field_definition.get("type", None))
+
+    field_type_kwargs = dict(
+        title=json_field_definition.get("title", None),
+        description=json_field_definition.get("description", None),
+        pattern=json_field_definition.get("pattern", None),
+    )
+
+    if "enum" in json_field_definition and isinstance(
+        json_field_definition["enum"], list
+    ):
+        python_type = Literal[tuple(json_field_definition["enum"])]  # type: ignore
+
+    if "$ref" in json_field_definition:
+        field_type_kwargs["json_schema_extra"] = {"$ref": json_field_definition["$ref"]}
+
+    return (Annotated[python_type, Field(None, **field_type_kwargs)], default_value)
+
+
+def python_field_definition_to_json(
+    python_field_definition: Tuple[Annotated, Any]
+) -> Dict[str, Any]:
+    """Get json field definition from python `typing.Annotated`
+
+    >>> from pydantic import Field
+    >>> from eodag.utils import Annotated
+    >>> python_field_definition_to_json(
+    ...     (Annotated[
+    ...         Optional[str],
+    ...         Field(None, description='Foo bar', json_schema_extra={'$ref': '/path/to/schema'})
+    ...     ], None)
+    ... )
+    {'type': ['string', 'null'], 'description': 'Foo bar', '$ref': '/path/to/schema'}
+
+    :param python_field_definition: the python field definition (tuple of annotated type, default value)
+    :returns: the python field definition
+    """
+    if (
+        not isinstance(python_field_definition, tuple)
+        or len(python_field_definition) != 2
+    ):
+        raise ValidationError(
+            "%s must be an instance of Tuple[Annotated, Any]" % python_field_definition
+        )
+
+    python_field_annotated = python_field_definition[0]
+
+    if get_origin(python_field_annotated) is not Annotated:
+        raise ValidationError(
+            "%s must be an instance of Annotated" % python_field_annotated
+        )
+
+    json_field_definition = dict()
+
+    python_field_args = get_args(python_field_annotated)
+
+    # enum & type
+    if get_origin(python_field_args[0]) is Literal:
+        enum_args = get_args(python_field_args[0])
+        json_field_definition["type"] = python_type_to_json(type(enum_args[0]))
+        json_field_definition["enum"] = list(enum_args)
+    # type
+    else:
+        field_type = python_type_to_json(python_field_args[0])
+        if field_type is not None:
+            json_field_definition["type"] = python_type_to_json(python_field_args[0])
+
+    if len(python_field_args) < 2:
+        return json_field_definition
+
+    # other definition args
+    title = getattr(python_field_args[1], "title", None)
+    if title is not None:
+        json_field_definition["title"] = title
+
+    description = getattr(python_field_args[1], "description", None)
+    if description is not None:
+        json_field_definition["description"] = description
+
+    pattern = getattr(python_field_args[1], "pattern", None)
+    if pattern is not None:
+        json_field_definition["pattern"] = description
+
+    if (
+        python_field_args[1].json_schema_extra is not None
+        and "$ref" in python_field_args[1].json_schema_extra
+    ):
+        json_field_definition["$ref"] = python_field_args[1].json_schema_extra["$ref"]
+
+    return json_field_definition
+
+
+def model_fields_to_annotated_tuple(
+    model_fields: Dict[str, FieldInfo]
+) -> Dict[str, Tuple[Annotated, Any]]:
+    """Convert BaseModel.model_fields from FieldInfo to Annotated tuple usable as create_model argument
+
+    >>> from pydantic import create_model
+    >>> some_model = create_model("some_model", foo=(str, None))
+    >>> fields_definitions = model_fields_to_annotated_tuple(some_model.model_fields)
+    >>> str(fields_definitions).replace('_extensions', '') # python3.8 compatibility
+    "{'foo': (typing.Annotated[str, FieldInfo(annotation=NoneType, required=False)], None)}"
+    >>> another_model = create_model("another_model", **fields_definitions)
+    >>> another_model(foo="abc")
+    another_model(foo='abc')
+
+    :param model_fields: BaseModel.model_fields to convert
+    :returns: Annotated tuple usable as create_model argument
+    """
+    annotated_model_fields = dict()
+    for param, field_info in model_fields.items():
+        field_type = field_info.annotation or type(None)
+        new_field_info = copy_deepcopy(field_info)
+        new_field_info.annotation = None
+        annotated_model_fields[param] = (Annotated[field_type, new_field_info], None)
+    return annotated_model_fields
diff --git a/eodag/types/bbox.py b/eodag/types/bbox.py
new file mode 100644
index 00000000..612f29d6
--- /dev/null
+++ b/eodag/types/bbox.py
@@ -0,0 +1,129 @@
+# -*- coding: utf-8 -*-
+# Copyright 2018, CS GROUP - France, https://www.csgroup.eu/
+#
+# This file is part of EODAG project
+#     https://www.github.com/CS-SI/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.
+from typing import Any, Dict, List, Tuple, Union
+
+from pydantic import BaseModel, validator
+from shapely.geometry.polygon import Polygon
+
+NumType = Union[float, int]
+BBoxArgs = Union[
+    List[NumType], Tuple[NumType, NumType, NumType, NumType], Dict[str, NumType]
+]
+
+
+class BBox(BaseModel):
+    """
+    A class used to represent a Bounding Box.
+    """
+
+    lonmin: NumType
+    latmin: NumType
+    lonmax: NumType
+    latmax: NumType
+
+    def __init__(__pydantic_self__, bboxArgs: BBoxArgs) -> None:  # type: ignore
+        """
+        Constructs all the necessary attributes for the BBox object.
+
+        :param bboxArgs: Four float values representing lonmin, latmin, lonmax, latmax respectively
+            or a dictionary containing key-value pairs of attribute names and values.
+        """
+        if isinstance(bboxArgs, (list, tuple)) and len(bboxArgs) == 4:
+            values = {
+                "lonmin": bboxArgs[0],
+                "latmin": bboxArgs[1],
+                "lonmax": bboxArgs[2],
+                "latmax": bboxArgs[3],
+            }
+
+        elif isinstance(bboxArgs, dict) and len(bboxArgs) == 4:
+            values = bboxArgs
+        else:
+            raise ValueError(
+                "Expected a dictionary, list or tuple with 4 values for lonmin, latmin, lonmax, latmax"
+            )
+        super().__init__(**values)
+
+    @validator("lonmin", "lonmax")
+    @classmethod
+    def validate_longitude(cls, v: NumType) -> NumType:
+        """
+        Validates the longitude values.
+
+        :param v: The longitude value to be validated.
+        :return: The validated longitude value.
+        """
+        if not -180 <= v <= 180:
+            raise ValueError("Longitude values must be between -180 and 180")
+        return v
+
+    @validator("latmin", "latmax")
+    @classmethod
+    def validate_latitude(cls, v: NumType) -> NumType:
+        """
+        Validates the latitude values.
+
+        :param v: The latitude value to be validated.
+        :return: The validated latitude value.
+        """
+        if not -90 <= v <= 90:
+            raise ValueError("Latitude values must be between -90 and 90")
+        return v
+
+    @validator("lonmax")
+    @classmethod
+    def validate_lonmax(cls, v: NumType, values: Dict[str, Any]) -> NumType:
+        """
+        Validates that lonmax is greater than lonmin.
+
+        :param v: The lonmax value to be validated.
+        :param values: A dictionary containing the current attribute values.
+        :return: The validated lonmax value.
+        """
+        if "lonmin" in values and v < values["lonmin"]:
+            raise ValueError("lonmax must be greater than lonmin")
+        return v
+
+    @validator("latmax")
+    @classmethod
+    def validate_latmax(cls, v: NumType, values: Dict[str, Any]) -> NumType:
+        """
+        Validates that latmax is greater than latmin.
+
+        :param v: The latmax value to be validated.
+        :param values: A dictionary containing the current attribute values.
+        :return: The validated latmax value.
+        """
+        if "latmin" in values and v < values["latmin"]:
+            raise ValueError("latmax must be greater than latmin")
+        return v
+
+    def to_polygon(self) -> Polygon:
+        """
+        Converts the bounding box to a polygon.
+
+        :return: The Polygon object representing the bounding box.
+        """
+        return Polygon(
+            (
+                (self.lonmin, self.latmin),
+                (self.lonmin, self.latmax),
+                (self.lonmax, self.latmax),
+                (self.lonmax, self.latmin),
+            )
+        )
diff --git a/eodag/types/queryables.py b/eodag/types/queryables.py
new file mode 100644
index 00000000..36025d49
--- /dev/null
+++ b/eodag/types/queryables.py
@@ -0,0 +1,124 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024, CS GROUP - France, https://www.csgroup.eu/
+#
+# This file is part of EODAG project
+#     https://www.github.com/CS-SI/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.
+from typing import Optional
+
+from annotated_types import Lt
+from pydantic import BaseModel, Field
+from pydantic.types import PositiveInt
+from typing_extensions import Annotated
+
+Percentage = Annotated[PositiveInt, Lt(100)]
+
+
+class CommonQueryables(BaseModel):
+    """A class representing search common queryable properties."""
+
+    productType: Annotated[str, Field()]
+    id: Annotated[Optional[str], Field(None)]
+    start: Annotated[Optional[str], Field(None, alias="startTimeFromAscendingNode")]
+    end: Annotated[Optional[str], Field(None, alias="completionTimeFromAscendingNode")]
+    geom: Annotated[Optional[str], Field(None, alias="geometry")]
+
+    @classmethod
+    def get_queryable_from_alias(cls, value: str) -> str:
+        """Get queryable parameter from alias
+
+        >>> CommonQueryables.get_queryable_from_alias('startTimeFromAscendingNode')
+        'start'
+        >>> CommonQueryables.get_queryable_from_alias('productType')
+        'productType'
+        """
+        alias_map = {
+            field_info.alias: name
+            for name, field_info in cls.model_fields.items()
+            if field_info.alias
+        }
+        return alias_map.get(value, value)
+
+
+class Queryables(CommonQueryables):
+    """A class representing all search queryable properties."""
+
+    uid: Annotated[Optional[str], Field(None)]
+    # OpenSearch Parameters for Collection Search (Table 3)
+    doi: Annotated[Optional[str], Field(None)]
+    platform: Annotated[Optional[str], Field(None)]
+    platformSerialIdentifier: Annotated[Optional[str], Field(None)]
+    instrument: Annotated[Optional[str], Field(None)]
+    sensorType: Annotated[Optional[str], Field(None)]
+    compositeType: Annotated[Optional[str], Field(None)]
+    processingLevel: Annotated[Optional[str], Field(None)]
+    orbitType: Annotated[Optional[str], Field(None)]
+    spectralRange: Annotated[Optional[str], Field(None)]
+    wavelengths: Annotated[Optional[str], Field(None)]
+    hasSecurityConstraints: Annotated[Optional[str], Field(None)]
+    dissemination: Annotated[Optional[str], Field(None)]
+    # INSPIRE obligated OpenSearch Parameters for Collection Search (Table 4)
+    title: Annotated[Optional[str], Field(None)]
+    topicCategory: Annotated[Optional[str], Field(None)]
+    keyword: Annotated[Optional[str], Field(None)]
+    abstract: Annotated[Optional[str], Field(None)]
+    resolution: Annotated[Optional[int], Field(None)]
+    organisationName: Annotated[Optional[str], Field(None)]
+    organisationRole: Annotated[Optional[str], Field(None)]
+    publicationDate: Annotated[Optional[str], Field(None)]
+    lineage: Annotated[Optional[str], Field(None)]
+    useLimitation: Annotated[Optional[str], Field(None)]
+    accessConstraint: Annotated[Optional[str], Field(None)]
+    otherConstraint: Annotated[Optional[str], Field(None)]
+    classification: Annotated[Optional[str], Field(None)]
+    language: Annotated[Optional[str], Field(None)]
+    specification: Annotated[Optional[str], Field(None)]
+    # OpenSearch Parameters for Product Search (Table 5)
+    parentIdentifier: Annotated[Optional[str], Field(None)]
+    productionStatus: Annotated[Optional[str], Field(None)]
+    acquisitionType: Annotated[Optional[str], Field(None)]
+    orbitNumber: Annotated[Optional[int], Field(None)]
+    orbitDirection: Annotated[Optional[str], Field(None)]
+    track: Annotated[Optional[str], Field(None)]
+    frame: Annotated[Optional[str], Field(None)]
+    swathIdentifier: Annotated[Optional[str], Field(None)]
+    cloudCover: Annotated[Optional[Percentage], Field(None)]
+    snowCover: Annotated[Optional[Percentage], Field(None)]
+    lowestLocation: Annotated[Optional[str], Field(None)]
+    highestLocation: Annotated[Optional[str], Field(None)]
+    productVersion: Annotated[Optional[str], Field(None)]
+    productQualityStatus: Annotated[Optional[str], Field(None)]
+    productQualityDegradationTag: Annotated[Optional[str], Field(None)]
+    processorName: Annotated[Optional[str], Field(None)]
+    processingCenter: Annotated[Optional[str], Field(None)]
+    creationDate: Annotated[Optional[str], Field(None)]
+    modificationDate: Annotated[Optional[str], Field(None)]
+    processingDate: Annotated[Optional[str], Field(None)]
+    sensorMode: Annotated[Optional[str], Field(None)]
+    archivingCenter: Annotated[Optional[str], Field(None)]
+    processingMode: Annotated[Optional[str], Field(None)]
+    # OpenSearch Parameters for Acquistion Parameters Search (Table 6)
+    availabilityTime: Annotated[Optional[str], Field(None)]
+    acquisitionStation: Annotated[Optional[str], Field(None)]
+    acquisitionSubType: Annotated[Optional[str], Field(None)]
+    illuminationAzimuthAngle: Annotated[Optional[str], Field(None)]
+    illuminationZenithAngle: Annotated[Optional[str], Field(None)]
+    illuminationElevationAngle: Annotated[Optional[str], Field(None)]
+    polarizationMode: Annotated[Optional[str], Field(None)]
+    polarizationChannels: Annotated[Optional[str], Field(None)]
+    antennaLookDirection: Annotated[Optional[str], Field(None)]
+    minimumIncidenceAngle: Annotated[Optional[float], Field(None)]
+    maximumIncidenceAngle: Annotated[Optional[float], Field(None)]
+    dopplerFrequency: Annotated[Optional[float], Field(None)]
+    incidenceAngleVariation: Annotated[Optional[float], Field(None)]
diff --git a/eodag/types/search_args.py b/eodag/types/search_args.py
new file mode 100644
index 00000000..ea2ca24d
--- /dev/null
+++ b/eodag/types/search_args.py
@@ -0,0 +1,83 @@
+# -*- coding: utf-8 -*-
+# Copyright 2018, CS GROUP - France, https://www.csgroup.eu/
+#
+# This file is part of EODAG project
+#     https://www.github.com/CS-SI/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.
+from datetime import datetime
+from typing import Dict, List, Optional, Tuple, Union, cast
+
+from pydantic import BaseModel, ConfigDict, Field, conint, field_validator
+from shapely import wkt
+from shapely.errors import GEOSException
+from shapely.geometry import Polygon, shape
+from shapely.geometry.base import GEOMETRY_TYPES, BaseGeometry
+
+from eodag.types.bbox import BBox
+from eodag.utils import DEFAULT_ITEMS_PER_PAGE, DEFAULT_PAGE
+
+NumType = Union[float, int]
+GeomArgs = Union[List[NumType], Tuple[NumType], Dict[str, NumType], str, BaseGeometry]
+
+PositiveInt = conint(gt=0)
+
+
+class SearchArgs(BaseModel):
+    """Represents an EODAG search"""
+
+    model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True)
+
+    provider: Optional[str] = Field(None)
+    productType: str = Field()
+    id: Optional[str] = Field(None)
+    start: Optional[str] = Field(None)
+    end: Optional[str] = Field(None)
+    geom: Optional[BaseGeometry] = Field(None)
+    locations: Optional[Dict[str, str]] = Field(None)
+    page: Optional[int] = Field(DEFAULT_PAGE, gt=0)  # type: ignore
+    items_per_page: Optional[PositiveInt] = Field(DEFAULT_ITEMS_PER_PAGE)  # type: ignore
+
+    @field_validator("start", "end", mode="before")
+    @classmethod
+    def check_date_format(cls, v: str) -> str:
+        """Validate dates"""
+        try:
+            datetime.fromisoformat(v)
+        except ValueError as e:
+            raise ValueError("start_date and end must follow ISO8601 format") from e
+        return v
+
+    @field_validator("geom", mode="before")
+    @classmethod
+    def check_geom(cls, v: GeomArgs) -> BaseGeometry:
+        """Validate geom"""
+        # GeoJSON geometry
+        if isinstance(v, dict) and v.get("type") in GEOMETRY_TYPES:
+            return cast(BaseGeometry, shape(v))
+
+        # Bounding Box
+        if isinstance(v, (list, tuple, dict)):
+            return BBox(v).to_polygon()
+
+        if isinstance(v, str):
+            # WKT geometry
+            try:
+                return cast(Polygon, wkt.loads(v))
+            except GEOSException as e:
+                raise ValueError(f"Invalid geometry WKT string: {v}") from e
+
+        if isinstance(v, BaseGeometry):
+            return v
+
+        raise TypeError(f"Invalid geometry type: {type(v)}")
diff --git a/eodag/utils/__init__.py b/eodag/utils/__init__.py
index 009ab0f5..666d974f 100644
--- a/eodag/utils/__init__.py
+++ b/eodag/utils/__init__.py
@@ -41,6 +41,7 @@ from copy import deepcopy as copy_deepcopy
 from datetime import datetime as dt
 from email.message import Message
 from glob import glob
+from importlib.metadata import metadata
 from itertools import repeat, starmap
 from pathlib import Path
 from tempfile import mkdtemp
@@ -70,6 +71,12 @@ from urllib.parse import (  # noqa; noqa
 )
 from urllib.request import url2pathname
 
+try:
+    from typing import Annotated, get_args, get_origin  # noqa
+except ImportError:
+    # for python < 3.9
+    from typing_extensions import Annotated, get_args, get_origin  # noqa
+
 import click
 import orjson
 import shapefile
@@ -93,11 +100,6 @@ if TYPE_CHECKING:
 
     from eodag.api.product import EOProduct
 
-try:
-    from importlib.metadata import metadata  # type: ignore
-except ImportError:  # pragma: no cover
-    # for python < 3.8
-    from importlib_metadata import metadata  # type: ignore
 
 logger = py_logging.getLogger("eodag.utils")
 
diff --git a/setup.cfg b/setup.cfg
index 6438485c..5ddda1cc 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -61,6 +61,7 @@ install_requires =
     cdsapi
     stream-zip
     requests-ftp
+    pydantic >= 2.1.0
 
 [options.extras_require]
 dev =


Test patch:
diff --git a/pytest.ini b/pytest.ini
index e1f2e410..56283ece 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,5 +1,6 @@
 [pytest]
 testpaths =
+    eodag/types
     eodag/utils
     tests
 addopts = --doctest-modules --disable-socket --allow-unix-socket
diff --git a/tests/context.py b/tests/context.py
index 3b4c1d73..41573767 100644
--- a/tests/context.py
+++ b/tests/context.py
@@ -65,6 +65,8 @@ from eodag.plugins.download.http import HTTPDownload
 from eodag.plugins.manager import PluginManager
 from eodag.plugins.search.base import Search
 from eodag.rest.stac import DEFAULT_MISSION_START_DATE
+from eodag.types import model_fields_to_annotated_tuple
+from eodag.types.queryables import CommonQueryables, Queryables
 from eodag.utils import (
     DEFAULT_STREAM_REQUESTS_TIMEOUT,
     HTTP_REQ_TIMEOUT,
diff --git a/tests/units/test_core.py b/tests/units/test_core.py
index 9b186289..2a992ae5 100644
--- a/tests/units/test_core.py
+++ b/tests/units/test_core.py
@@ -32,16 +32,17 @@ from shapely import wkt
 from shapely.geometry import LineString, MultiPolygon, Polygon
 
 from eodag import __version__ as eodag_version
-from eodag.api.queryables import BaseQueryableProperty, Queryables
 from eodag.utils import GENERIC_PRODUCT_TYPE
 from tests import TEST_RESOURCES_PATH
 from tests.context import (
     DEFAULT_MAX_ITEMS_PER_PAGE,
+    CommonQueryables,
     EODataAccessGateway,
     EOProduct,
     NoMatchingProductType,
     PluginImplementationError,
     ProviderConfig,
+    Queryables,
     RequestError,
     SearchResult,
     UnsupportedProductType,
@@ -49,6 +50,7 @@ from tests.context import (
     get_geometry_from_various,
     load_default_config,
     makedirs,
+    model_fields_to_annotated_tuple,
 )
 from tests.utils import mock, write_eodag_conf_with_fake_credentials
 
@@ -1011,93 +1013,48 @@ class TestCore(TestCoreBase):
         # run a 2nd time: check that it does not raise an error
         self.dag.update_providers_config(new_config)
 
-    def test_get_queryables(self):
-        """get_queryables must return queryables list adapted to provider and product-type"""
+    @mock.patch(
+        "eodag.plugins.search.qssearch.StacSearch.discover_queryables", autospec=True
+    )
+    @mock.patch(
+        "eodag.api.core.EODataAccessGateway.fetch_product_types_list", autospec=True
+    )
+    def test_list_queryables(
+        self, mock_discover_queryables, mock_fetch_product_types_list
+    ):
+        """list_queryables must return queryables list adapted to provider and product-type"""
         with self.assertRaises(UnsupportedProvider):
             self.dag.list_queryables(provider="not_existing_provider")
 
         with self.assertRaises(UnsupportedProductType):
             self.dag.list_queryables(product_type="not_existing_product_type")
 
-        expected_result = Queryables().get_base_properties()
-        queryables = self.dag.list_queryables()
-        self.assertDictEqual(expected_result, queryables)
-
-        expected_properties = {
-            "Product Type",
-            "Organisation Name",
-            "Parent Identifier",
-            "Swath Identifier",
-            "Cloud Cover",
-            "Snow Cover",
-            "Polarization Mode",
-            "Tile Identifier",
-        }
-        for property in expected_properties:
-            key_parts = property.split(" ")
-            key = key_parts[0].lower()
-            if len(key_parts) > 1:
-                key += key_parts[1]
-            expected_result[key] = BaseQueryableProperty(description=property)
-        expected_result["platformSerialIdentifier"] = BaseQueryableProperty(
-            description="Platform"
-        )
-        expected_result["resolution"] = BaseQueryableProperty(description="Gsd")
-        expected_result["orbitNumber"] = BaseQueryableProperty(
-            description="Absolute Orbit"
-        )
-        expected_result["orbitDirection"] = BaseQueryableProperty(
-            description="Orbit State"
-        )
-        expected_result["processingLevel"] = BaseQueryableProperty(description="Level")
-        expected_result["instrument"] = BaseQueryableProperty(description="Instruments")
-        expected_result["sensorMode"] = BaseQueryableProperty(
-            description="Instrument Mode"
-        )
-        queryables = self.dag.list_queryables(provider="peps")
-        self.assertDictEqual(expected_result, queryables)
-
-        expected_properties = {
-            "Product Type",
-            "Organisation Name",
-            "Parent Identifier",
-            "Swath Identifier",
-            "Snow Cover",
-            "Polarization Mode",
-            "Tile Identifier",
-        }
-        expected_result = Queryables().get_base_properties()
-        for property in expected_properties:
-            key_parts = property.split(" ")
-            key = key_parts[0].lower()
-            if len(key_parts) > 1:
-                key += key_parts[1]
-            expected_result[key] = BaseQueryableProperty(description=property)
-        expected_result["platformSerialIdentifier"] = BaseQueryableProperty(
-            description="Platform"
-        )
-        expected_result["resolution"] = BaseQueryableProperty(description="Gsd")
-        expected_result["orbitNumber"] = BaseQueryableProperty(
-            description="Absolute Orbit"
-        )
-        expected_result["orbitDirection"] = BaseQueryableProperty(
-            description="Orbit State"
-        )
-        expected_result["processingLevel"] = BaseQueryableProperty(description="Level")
-        expected_result["instrument"] = BaseQueryableProperty(description="Instruments")
-        expected_result["sensorMode"] = BaseQueryableProperty(
-            description="Instrument Mode"
-        )
-
-        queryables = self.dag.list_queryables(
-            provider="peps", product_type="S1_SAR_GRD"
+        queryables_none_none = self.dag.list_queryables()
+        expected_result = model_fields_to_annotated_tuple(CommonQueryables.model_fields)
+        self.assertEqual(len(queryables_none_none), len(expected_result))
+        for key, queryable in queryables_none_none.items():
+            # compare obj.__repr__
+            self.assertEqual(str(expected_result[key]), str(queryable))
+
+        queryables_peps_none = self.dag.list_queryables(provider="peps")
+        expected_longer_result = model_fields_to_annotated_tuple(
+            Queryables.model_fields
         )
-        self.assertDictEqual(queryables, expected_result)
+        self.assertGreater(len(queryables_peps_none), len(queryables_none_none))
+        self.assertLess(len(queryables_peps_none), len(expected_longer_result))
+        for key, queryable in queryables_peps_none.items():
+            # compare obj.__repr__
+            self.assertEqual(str(expected_longer_result[key]), str(queryable))
 
-        queryables = self.dag.list_queryables(product_type="S2_MSI_L1C")
-        self.assertIn("awsPath", queryables.keys())
-        self.assertIn("id", queryables.keys())
-        self.assertIn("illuminationAzimuthAngle", queryables.keys())
+        queryables_peps_s1grd = self.dag.list_queryables(
+            provider="peps", product_type="S1_SAR_GRD"
+        )
+        self.assertGreater(len(queryables_peps_s1grd), len(queryables_none_none))
+        self.assertLess(len(queryables_peps_s1grd), len(queryables_peps_none))
+        self.assertLess(len(queryables_peps_s1grd), len(expected_longer_result))
+        for key, queryable in queryables_peps_s1grd.items():
+            # compare obj.__repr__
+            self.assertEqual(str(expected_longer_result[key]), str(queryable))
 
 
 class TestCoreConfWithEnvVar(TestCoreBase):
diff --git a/tests/units/test_http_server.py b/tests/units/test_http_server.py
index 0cfdc1ea..20aac0ea 100644
--- a/tests/units/test_http_server.py
+++ b/tests/units/test_http_server.py
@@ -32,6 +32,7 @@ from eodag.utils import USER_AGENT, MockResponse
 from tests import mock
 from tests.context import (
     DEFAULT_ITEMS_PER_PAGE,
+    HTTP_REQ_TIMEOUT,
     TEST_RESOURCES_PATH,
     AuthenticationError,
     SearchResult,
@@ -1038,11 +1039,12 @@ class RequestTestCase(unittest.TestCase):
         """Request to /queryables should return a valid response."""
         self._request_valid("queryables", check_links=False)
 
-    @mock.patch("eodag.api.core.requests.get", autospec=True)
+    @mock.patch("eodag.plugins.search.qssearch.requests.get", autospec=True)
     def test_queryables_with_provider(self, mock_requests_get):
         self._request_valid("queryables?provider=planetary_computer", check_links=False)
         mock_requests_get.assert_called_once_with(
-            url="https://planetarycomputer.microsoft.com/api/stac/v1/queryables",
+            url="https://planetarycomputer.microsoft.com/api/stac/v1/search/../queryables",
+            timeout=HTTP_REQ_TIMEOUT,
             headers=USER_AGENT,
         )
 
@@ -1052,7 +1054,7 @@ class RequestTestCase(unittest.TestCase):
             f"collections/{self.tested_product_type}/queryables", check_links=False
         )
 
-    @mock.patch("eodag.api.core.requests.get", autospec=True)
+    @mock.patch("eodag.plugins.search.qssearch.requests.get", autospec=True)
     def test_product_type_queryables_with_provider(self, mock_requests_get):
         """Request a collection-specific list of queryables for a given provider."""
         queryables_path = os.path.join(TEST_RESOURCES_PATH, "stac/queryables.json")
@@ -1061,22 +1063,41 @@ class RequestTestCase(unittest.TestCase):
         mock_requests_get.return_value = MockResponse(
             provider_queryables, status_code=200
         )
+
+        # no provider specified (only 1 available for the moment) : queryables intresection returned
         res_no_provider = self._request_valid(
             "collections/S1_SAR_GRD/queryables",
             check_links=False,
         )
+        mock_requests_get.assert_called_once_with(
+            url="https://planetarycomputer.microsoft.com/api/stac/v1/search/../collections/"
+            "sentinel-1-grd/queryables",
+            timeout=HTTP_REQ_TIMEOUT,
+            headers=USER_AGENT,
+        )
+        # returned queryables
+        self.assertListEqual(
+            list(res_no_provider["properties"].keys()),
+            ["id", "collection", "geometry", "bbox", "datetime", "collections"],
+        )
+        self.assertIn("collection", res_no_provider["properties"])
+        self.assertNotIn("s1:processing_level", res_no_provider["properties"])
+
+        mock_requests_get.reset_mock()
+
+        # provider specified
         res = self._request_valid(
             "collections/S1_SAR_GRD/queryables?provider=planetary_computer",
             check_links=False,
         )
         mock_requests_get.assert_called_once_with(
-            url="https://planetarycomputer.microsoft.com/api/stac/v1/collections/"
+            url="https://planetarycomputer.microsoft.com/api/stac/v1/search/../collections/"
             "sentinel-1-grd/queryables",
+            timeout=HTTP_REQ_TIMEOUT,
             headers=USER_AGENT,
         )
         # property added from provider queryables
         self.assertIn("s1:processing_level", res["properties"])
-        self.assertNotIn("s1:processing_level", res_no_provider["properties"])
         # property updated with info from provider queryables
         self.assertIn("platform", res["properties"])
         self.assertEqual("string", res["properties"]["platform"]["type"][0])