Repository: CS-SI/eodag

Issue:
server mode queryables
Add queryables, available through `/queryables` and `/collections/<PRODUCT_TYPE>/queryables`.

See https://github.com/radiantearth/stac-api-spec/blob/master/fragments/filter/README.md#queryables for documentation and https://planetarycomputer.microsoft.com/api/stac/v1/collections/sentinel-2-l2a/queryables for an example.

- `/queryables` should be minimal
- `/collections/<PRODUCT_TYPE>/queryables` should be build using product type queryable parameters from `providers.yml`




Discussion:
github-actions[bot]: ## Test Results
    2 files   -     2      2 suites   - 2   2m 28s [:stopwatch:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.9.0/README.md#the-symbols "duration of all tests") - 3m 1s
406 tests +    3  348 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.9.0/README.md#the-symbols "passed tests")  -   53  2 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.9.0/README.md#the-symbols "skipped / disabled tests") ±  0  0 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.9.0/README.md#the-symbols "failed tests") ±0  56 [:fire:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.9.0/README.md#the-symbols "test errors") +56 
812 runs   - 800  752 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.9.0/README.md#the-symbols "passed tests")  - 800  4 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.9.0/README.md#the-symbols "skipped / disabled tests")  - 56  0 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.9.0/README.md#the-symbols "failed tests") ±0  56 [:fire:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.9.0/README.md#the-symbols "test errors") +56 

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

Results for commit a986c1a4. ± Comparison against base commit 90627560.

[test-results]:data:application/gzip;base64,H4sIAGNl6GQC/1WMyw6DIBQFf8Ww7gIQEPszBi+QkKo0PFZN/73X+mqXM+dkXsSHyWVyb/itIbmGcoKtyZQQF0QmNAqcyjoKqg4acgVA1V6HIT/Cc09swpswoaCncCnFhEaunVSXNaoZ3+FodvIyW1Ic/FP88l8Q4jyHgkRMrxUwI2CUngJXVksYWWeV9Zb3VDNhKGu9I+8PpTUEkgcBAAA=

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

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

_Minimum allowed coverage is `70%`_

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

| File | Coverage |   |
| - | :-: | :-: |
| **All files** | `32%` | :x: |

_Minimum allowed coverage is `70%`_

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

Patch:
diff --git a/eodag/rest/server.py b/eodag/rest/server.py
index 57cc4381..541da3a8 100755
--- a/eodag/rest/server.py
+++ b/eodag/rest/server.py
@@ -18,11 +18,12 @@
 import io
 import logging
 import os
+import re
 import traceback
 from contextlib import asynccontextmanager
 from distutils import dist
 from json.decoder import JSONDecodeError
-from typing import List, Union
+from typing import List, Optional, Union
 
 import pkg_resources
 from fastapi import APIRouter as FastAPIRouter
@@ -37,8 +38,11 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
 
 from eodag.config import load_stac_api_config
 from eodag.rest.utils import (
+    QueryableProperty,
+    Queryables,
     download_stac_item_by_id_stream,
     eodag_api_init,
+    fetch_collection_queryable_properties,
     get_detailled_collections_list,
     get_stac_api_version,
     get_stac_catalogs,
@@ -61,6 +65,7 @@ from eodag.utils.exceptions import (
 )
 
 logger = logging.getLogger("eodag.rest.server")
+CAMEL_TO_SPACE_TITLED = re.compile(r"[:_-]|(?<=[a-z])(?=[A-Z])")
 
 
 class APIRouter(FastAPIRouter):
@@ -595,4 +600,57 @@ async def stac_catalogs(catalogs, request: Request):
     return jsonable_encoder(response)
 
 
+@router.get("/queryables", tags=["Capabilities"], response_model_exclude_none=True)
+def list_queryables(request: Request) -> Queryables:
+    """Returns the list of terms available for use when writing filter expressions.
+
+    This endpoint provides a list of terms that can be used as filters when querying
+    the data. These terms correspond to properties that can be filtered using comparison
+    operators.
+
+    :param request: The incoming request object.
+    :type request: fastapi.Request
+    :returns: An object containing the list of available queryable terms.
+    :rtype: eodag.rest.utils.Queryables
+    """
+
+    return Queryables(q_id=request.state.url)
+
+
+@router.get(
+    "/collections/{collection_id}/queryables",
+    tags=["Capabilities"],
+    response_model_exclude_none=True,
+)
+def list_collection_queryables(
+    request: Request, collection_id: str, provider: Optional[str] = None
+) -> Queryables:
+    """Returns the list of queryable properties for a specific collection.
+
+    This endpoint provides a list of properties that can be used as filters when querying
+    the specified collection. These properties correspond to characteristics of the data
+    that can be filtered using comparison operators.
+
+    :param request: The incoming request object.
+    :type request: fastapi.Request
+    :param collection_id: The identifier of the collection for which to retrieve queryable properties.
+    :type collection_id: str
+    :param provider: (optional) The provider for which to retrieve additional properties.
+    :type provider: str
+    :returns: An object containing the list of available queryable properties for the specified collection.
+    :rtype: eodag.rest.utils.Queryables
+    """
+
+    queryables = Queryables(q_id=request.state.url, additional_properties=False)
+    conf_args = [collection_id, provider] if provider else [collection_id]
+
+    provider_properties = set(fetch_collection_queryable_properties(*conf_args))
+
+    for prop in provider_properties:
+        titled_name = re.sub(CAMEL_TO_SPACE_TITLED, " ", prop).title()
+        queryables[prop] = QueryableProperty(description=titled_name)
+
+    return queryables
+
+
 app.include_router(router)
diff --git a/eodag/rest/utils.py b/eodag/rest/utils.py
index 8fbda850..76ab874c 100644
--- a/eodag/rest/utils.py
+++ b/eodag/rest/utils.py
@@ -9,10 +9,12 @@ import os
 import re
 from collections import namedtuple
 from shutil import make_archive, rmtree
+from typing import Dict, Optional
 
 import dateutil.parser
 from dateutil import tz
 from fastapi.responses import StreamingResponse
+from pydantic import BaseModel, Field
 from shapely.geometry import Polygon, shape
 
 import eodag
@@ -145,7 +147,6 @@ def search_bbox(request_bbox):
     search_bbox_keys = ["lonmin", "latmin", "lonmax", "latmax"]
 
     if request_bbox:
-
         try:
             request_bbox_list = [float(coord) for coord in request_bbox.split(",")]
         except ValueError as e:
@@ -883,6 +884,135 @@ def get_stac_extension_oseo(url):
     )
 
 
+class QueryableProperty(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
+    """
+
+    description: str
+    ref: Optional[str] = Field(default=None, serialization_alias="$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",
+            ),
+            "ids": QueryableProperty(description="IDs"),
+        }
+    )
+    additional_properties: bool = Field(
+        default=True, serialization_alias="additionalProperties"
+    )
+
+    def get_properties(self) -> Dict[str, QueryableProperty]:
+        """Get the queryable properties.
+
+        :returns: A dictionary containing queryable properties.
+        :rtype: typing.Dict[str, QueryableProperty]
+        """
+        return self.properties
+
+    def __contains__(self, name: str):
+        return name in self.properties
+
+    def __setitem__(self, name: str, qprop: QueryableProperty):
+        self.properties[name] = qprop
+
+
+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_properties = stac_config["item"]["properties"]
+
+    for stac_property, value in stac_config_properties.items():
+        if str(value).endswith(key):
+            return stac_property
+    return key
+
+
+def fetch_collection_queryable_properties(
+    collection_id: str, provider: Optional[str] = None
+) -> set:
+    """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
+    """
+    # Fetch the metadata mapping for collection-specific queryables
+    args = [collection_id, provider] if provider else [collection_id]
+    search_plugin = next(eodag_api._plugins_manager.get_search_plugins(*args))
+    mapping = dict(search_plugin.config.metadata_mapping)
+
+    # list of all the STAC standardized collection-specific queryables
+    queryable_properties = set()
+    for key, value in mapping.items():
+        if isinstance(value, list) and "TimeFromAscendingNode" not in key:
+            queryable_properties.add(rename_to_stac_standard(key))
+    return queryable_properties
+
+
 def eodag_api_init():
     """Init EODataAccessGateway server instance, pre-running all time consuming tasks"""
     eodag_api.fetch_product_types_list()


Test patch:
diff --git a/tests/units/test_http_server.py b/tests/units/test_http_server.py
index 9a144cd8..8cfe0926 100644
--- a/tests/units/test_http_server.py
+++ b/tests/units/test_http_server.py
@@ -760,3 +760,18 @@ class RequestTestCase(unittest.TestCase):
         response = self._request_valid("/extensions/oseo/json-schema/schema.json")
         self.assertEqual(response["title"], "OpenSearch for Earth Observation")
         self.assertEqual(response["allOf"][0]["$ref"], "#/definitions/oseo")
+
+    def test_queryables(self):
+        """Request to /queryables should return a valid response."""
+        self._request_valid("queryables")
+
+    def test_product_type_queryables(self):
+        """Request to /collections/{collection_id}/queryables should return a valid response."""
+        self._request_valid(f"collections/{self.tested_product_type}/queryables")
+
+    def test_product_type_queryables_with_provider(self):
+        """Request a collection-specific list of queryables for a given provider."""
+
+        self._request_valid(
+            f"collections/{self.tested_product_type}/queryables?provider=peps"
+        )