diff --git a/stac_fastapi/eodag/api.py b/stac_fastapi/eodag/api.py deleted file mode 100644 index 5e984107..00000000 --- a/stac_fastapi/eodag/api.py +++ /dev/null @@ -1,58 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2024, CS GROUP - France, https://www.cs-soprasteria.com -# -# This file is part of stac-fastapi-eodag project -# https://www.github.com/CS-SI/stac-fastapi-eodag -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -"""override stac api for download handlers.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import attr -from stac_fastapi.api.app import StacApi - -from stac_fastapi.eodag.models.stac_metadata import CommonStacMetadata - -if TYPE_CHECKING: - from pydantic import BaseModel - - -@attr.s -class EodagStacApi(StacApi): - """Override default API to include download endpoints handlers.""" - - item_properties_model: type[BaseModel] = attr.ib(default=CommonStacMetadata) - - # def register_download_item(self) -> None: - # """Register download item endpoint (GET /collections/{collection_id}/items/{item_id}/download). - - # Returns: - # None - # """ - # self.router.add_api_route( - # name="Download Item", - # path="/collections/{collection_id}/items/{item_id}/download", - # response_class=StreamingResponse, - # methods=["GET"], - # endpoint=create_async_endpoint( - # self.client.download_item, ItemUri, StreamingResponse - # ), - # ) - - # def register_core(self) -> None: - # """Register endpoints.""" - # super().register_core() - # self.register_download_item() diff --git a/stac_fastapi/eodag/core.py b/stac_fastapi/eodag/core.py index 618802d6..fda9c908 100644 --- a/stac_fastapi/eodag/core.py +++ b/stac_fastapi/eodag/core.py @@ -21,13 +21,13 @@ import asyncio import logging +import re from typing import TYPE_CHECKING, Any, cast from urllib.parse import unquote_plus import attr import orjson from fastapi import HTTPException -from fastapi.responses import StreamingResponse from pydantic import ValidationError from pydantic_core import InitErrorDetails, PydanticCustomError from pygeofilter.backends.cql2_json import to_cql2 @@ -50,17 +50,14 @@ from stac_fastapi.eodag.constants import DEFAULT_ITEMS_PER_PAGE from stac_fastapi.eodag.cql_evaluate import EodagEvaluator from stac_fastapi.eodag.errors import NoMatchingCollection, ResponseSearchError +from stac_fastapi.eodag.models.item import create_stac_item from stac_fastapi.eodag.models.links import ( CollectionLinks, CollectionSearchPagingLinks, ItemCollectionLinks, PagingLinks, ) -from stac_fastapi.eodag.models.stac_metadata import ( - CommonStacMetadata, - create_stac_item, - get_sortby_to_post, -) +from stac_fastapi.eodag.models.stac_metadata import CommonStacMetadata from stac_fastapi.eodag.utils import ( check_poly_is_point, dt_range_to_eodag, @@ -371,10 +368,11 @@ async def item_collection( bbox: Optional[list[NumType]] = None, datetime: Optional[str] = None, limit: Optional[int] = None, - page: Optional[str] = None, + # extensions sortby: Optional[list[str]] = None, filter_expr: Optional[str] = None, filter_lang: Optional[str] = "cql2-text", + token: Optional[str] = None, **kwargs: Any, ) -> ItemCollection: """ @@ -387,10 +385,10 @@ async def item_collection( :param bbox: Bounding box to filter the items. :param datetime: Date and time range to filter the items. :param limit: Maximum number of items to return. - :param page: Page token for pagination. :param sortby: List of fields to sort the results by. :param filter_expr: CQL filter to apply to the search. :param filter_lang: Language of the filter (default is "cql2-text"). + :param token: Page token for pagination. :param kwargs: Additional arguments. :returns: An ItemCollection. :raises NotFoundError: If the collection does not exist. @@ -403,20 +401,10 @@ async def item_collection( "bbox": bbox, "datetime": datetime, "limit": limit, - "page": page, + "token": token, } - if sortby: - sortby_converted = get_sortby_to_post(sortby) - base_args["sortby"] = cast(Any, sortby_converted) - - if filter_expr: - add_filter_to_args(base_args, filter_lang, filter_expr) - - clean = {} - for k, v in base_args.items(): - if v is not None and v != []: - clean[k] = v + clean = self._clean_search_args(base_args, sortby=sortby, filter_expr=filter_expr, filter_lang=filter_lang) search_request = self.post_request_model.model_validate(clean) item_collection = self._search_base(search_request, request) @@ -444,12 +432,12 @@ def get_search( collections: Optional[list[str]] = None, ids: Optional[list[str]] = None, bbox: Optional[list[NumType]] = None, + intersects: Optional[str] = None, datetime: Optional[str] = None, limit: Optional[int] = None, + # Extensions query: Optional[str] = None, - page: Optional[str] = None, sortby: Optional[list[str]] = None, - intersects: Optional[str] = None, filter_expr: Optional[str] = None, filter_lang: Optional[str] = "cql2-text", token: Optional[str] = None, @@ -462,14 +450,14 @@ def get_search( :param collections: List of collection IDs to include in the search. :param ids: List of item IDs to include in the search. :param bbox: Bounding box to filter the search. + :param intersects: GeoJSON geometry to filter the search. :param datetime: Date and time range to filter the search. :param limit: Maximum number of items to return. :param query: Query string to filter the search. - :param page: Page token for pagination. :param sortby: List of fields to sort the results by. - :param intersects: GeoJSON geometry to filter the search. :param filter_expr: CQL filter to apply to the search. - :param filter_lang: Language of the filter (default is "cql2-text"). + :param filter_lang: Language of the filter. + :param token: Page token for pagination. :param kwargs: Additional arguments. :returns: Found items. :raises HTTPException: If the provided parameters are invalid. @@ -479,23 +467,18 @@ def get_search( "ids": ids, "bbox": bbox, "limit": limit, - "query": orjson.loads(unquote_plus(query)) if query else query, "token": token, - "sortby": get_sortby_to_post(sortby), - "intersects": orjson.loads(unquote_plus(intersects)) if intersects else intersects, } - if datetime: - base_args["datetime"] = format_datetime_range(datetime) - - if filter_expr: - add_filter_to_args(base_args, filter_lang, filter_expr) - - # Remove None values from dict - clean = {} - for k, v in base_args.items(): - if v is not None and v != []: - clean[k] = v + clean = self._clean_search_args( + base_args, + intersects=intersects, + datetime=datetime, + sortby=sortby, + query=query, + filter_expr=filter_expr, + filter_lang=filter_lang, + ) try: search_request = self.post_request_model(**clean) @@ -525,40 +508,53 @@ async def get_item(self, item_id: str, collection_id: str, request: Request, **k return Item(**item_collection["features"][0]) - async def download_item(self, item_id: str, collection_id: str, request: Request, **kwargs) -> StreamingResponse: - """ - Download item by ID. + def _clean_search_args( + self, + base_args: dict[str, Any], + intersects: Optional[str] = None, + datetime: Optional[str] = None, + sortby: Optional[str] = None, + query: Optional[str] = None, + filter_expr: Optional[str] = None, + filter_lang: Optional[str] = None, + **kwargs: Any, + ) -> dict[str, Any]: + """Clean up search arguments to match format expected by pgstac""" + if filter_expr: + if filter_lang == "cql2-text": + filter_expr = to_cql2(parse_cql2_text(filter_expr)) + filter_lang = "cql2-json" - :param item_id: ID of the item. - :param collection_id: ID of the collection. - :param request: The request object. - :param kwargs: Additional arguments. - :returns: Streaming response for the item download. - """ - product: EOProduct - product, _ = request.app.state.dag.search({"collection": collection_id, "id": item_id})[0] - - # when could this really happen ? - if not product.downloader: - download_plugin = request.app.state.dag._plugins_manager.get_download_plugin(product) - auth_plugin = request.app.state.dag._plugins_manager.get_auth_plugin(download_plugin.provider) - product.register_downloader(download_plugin, auth_plugin) - - # required for auth. Can be removed when EODAG implements the auth interface - auth = ( - product.downloader_auth.authenticate() if product.downloader_auth is not None else product.downloader_auth - ) + base_args["filter"] = str2json("filter_expr", filter_expr) + base_args["filter_lang"] = "cql2-json" - if product.downloader is None: - raise HTTPException(status_code=500, detail="No downloader found for this product") - # can we make something more clean here ? - download_stream_dict = product.downloader._stream_download_dict(product, auth=auth) + if datetime: + base_args["datetime"] = format_datetime_range(datetime) - return StreamingResponse( - content=download_stream_dict.content, - headers=download_stream_dict.headers, - media_type=download_stream_dict.media_type, - ) + if query: + base_args["query"] = orjson.loads(unquote_plus(query)) + + if intersects: + base_args["intersects"] = orjson.loads(unquote_plus(intersects)) + + if sortby: + sort_param = [] + for sort in sortby: + sortparts = re.match(r"^([+-]?)(.*)$", sort) + if sortparts: + sort_param.append({ + "field": sortparts.group(2).strip(), + "direction": "desc" if sortparts.group(1) == "-" else "asc", + }) + base_args["sortby"] = sort_param + + # Remove None values from dict + clean = {} + for k, v in base_args.items(): + if v is not None and v != []: + clean[k] = v + + return clean def prepare_search_base_args(search_request: BaseSearchPostRequest, model: type[CommonStacMetadata]) -> dict[str, Any]: @@ -598,15 +594,13 @@ def prepare_search_base_args(search_request: BaseSearchPostRequest, model: type[ param_tuples = [] for param in sortby: dumped_param = param.model_dump(mode="json") - param_tuples.append( - ( - sort_by_special_fields.get( - model.to_eodag(dumped_param["field"]), - model.to_eodag(dumped_param["field"]), - ), - dumped_param["direction"], - ) - ) + param_tuples.append(( + sort_by_special_fields.get( + model.to_eodag(dumped_param["field"]), + model.to_eodag(dumped_param["field"]), + ), + dumped_param["direction"], + )) sort_by["sort_by"] = param_tuples eodag_query = {} @@ -749,9 +743,7 @@ def eodag_search_next_page(dag, eodag_args): next_page_token = eodag_args.pop("token", None) provider = eodag_args.get("provider") if not next_page_token or not provider: - raise HTTPException( - status_code=500, detail="Missing required token and federation backend for next page search." - ) + raise ValueError("Missing required token and federation backend for next page search.") search_plugin = next(dag._plugins_manager.get_search_plugins(provider=provider)) next_page_token_key = getattr(search_plugin.config, "pagination", {}).get("next_page_token_key", "page") eodag_args.pop("count", None) @@ -769,18 +761,3 @@ def eodag_search_next_page(dag, eodag_args): logger.info("StopIteration encountered during next page search.") search_result = SearchResult([]) return search_result - - -def add_filter_to_args(base_args: dict[str, Any], filter_lang: Optional[str], filter_expr: Optional[str]): - """Parse the filter from the query and add to arguments - - :param base_args: - :param filter_expr: CQL filter to apply to the search. - :param filter_lang: Language of the filter (default is "cql2-text"). - """ - if filter_lang == "cql2-text": - filter_expr = to_cql2(parse_cql2_text(filter_expr)) - filter_lang = "cql2-json" - - base_args["filter"] = str2json("filter_expr", filter_expr) - base_args["filter_lang"] = "cql2-json" diff --git a/stac_fastapi/eodag/extensions/collection_order.py b/stac_fastapi/eodag/extensions/collection_order.py index ec6cde38..61b9ba0b 100644 --- a/stac_fastapi/eodag/extensions/collection_order.py +++ b/stac_fastapi/eodag/extensions/collection_order.py @@ -38,10 +38,8 @@ from stac_fastapi.eodag.config import get_settings from stac_fastapi.eodag.errors import ResponseSearchError -from stac_fastapi.eodag.models.stac_metadata import ( - CommonStacMetadata, - create_stac_item, -) +from stac_fastapi.eodag.models.item import create_stac_item +from stac_fastapi.eodag.models.stac_metadata import CommonStacMetadata logger = logging.getLogger(__name__) diff --git a/stac_fastapi/eodag/models/item.py b/stac_fastapi/eodag/models/item.py new file mode 100644 index 00000000..66e825c7 --- /dev/null +++ b/stac_fastapi/eodag/models/item.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +# Copyright 2025, CS GROUP - France, https://www.cs-soprasteria.com +# +# This file is part of stac-fastapi-eodag project +# https://www.github.com/CS-SI/stac-fastapi-eodag +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""stac item.""" + +from typing import Any, Callable, Optional +from urllib.parse import quote, unquote_plus, urlparse + +import orjson +from fastapi import Request +from stac_fastapi.types.errors import NotFoundError +from stac_fastapi.types.requests import get_base_url +from stac_fastapi.types.stac import Item +from stac_pydantic.api.version import STAC_API_VERSION +from stac_pydantic.shared import Asset + +from eodag.api.product._product import EOProduct +from eodag.api.product.metadata_mapping import OFFLINE_STATUS, ONLINE_STATUS +from eodag.utils import deepcopy, guess_file_type +from stac_fastapi.eodag.config import Settings, get_settings +from stac_fastapi.eodag.errors import MisconfiguredError +from stac_fastapi.eodag.models.links import ItemLinks +from stac_fastapi.eodag.models.stac_metadata import CommonStacMetadata + + +def _get_retrieve_body_for_order(product: EOProduct) -> dict[str, Any]: + """returns the body of the request used to order a product""" + parts = urlparse(product.properties["eodag:order_link"]) + keys = ["request", "inputs", "location"] # keys used by different providers + request_dict = orjson.loads(parts.query) + retrieve_body = None + for key in keys: + if key in request_dict: + retrieve_body = request_dict[key] + if isinstance(retrieve_body, str): # order link is quoted json or url + try: + retrieve_body = orjson.loads(unquote_plus(retrieve_body)) + except ValueError: # string is a url not a geojson -> no body required + retrieve_body = {} + elif not isinstance(retrieve_body, dict): + raise MisconfiguredError("order_link must include a dict with key request, inputs or location") + return retrieve_body + + +def create_stac_item( + product: EOProduct, + model: type[CommonStacMetadata], + extension_is_enabled: Callable[[str], bool], + request: Request, + extension_names: Optional[list[str]], + request_json: Optional[Any] = None, +) -> Item: + """Create a STAC item from an EODAG product""" + if product.collection is None: + raise NotFoundError("A STAC item can not be created from an EODAG EOProduct without collection") + + settings: Settings = get_settings() + + collection = request.app.state.dag.collections_config.source.get(product.collection, {}).get( + "alias", product.collection + ) + + feature = Item( + type="Feature", + assets={}, + id=product.properties["id"], + geometry=product.geometry.__geo_interface__, + bbox=product.geometry.bounds, + collection=collection, + stac_version=STAC_API_VERSION, + ) + + stac_extensions: set[str] = set() + + download_base_url = settings.download_base_url + if not download_base_url: + download_base_url = get_base_url(request) + + quoted_id = quote(feature["id"]) + asset_proxy_url = ( + (download_base_url + f"data/{product.provider}/{collection}/{quoted_id}") + if extension_is_enabled("DataDownload") + else None + ) + + settings = get_settings() + auto_order_whitelist = settings.auto_order_whitelist + if product.provider in auto_order_whitelist: + # a product from a whitelisted federation backend is considered as online + product.properties["order:status"] = ONLINE_STATUS + + # create assets only if product is not offline + if ( + product.properties.get("order:status", ONLINE_STATUS) != OFFLINE_STATUS + or product.provider in auto_order_whitelist + ): + for k, v in product.assets.items(): + # TODO: download extension with origin link (make it optional ?) + asset_model = Asset.model_validate(v) + feature["assets"][k] = asset_model.model_dump(exclude_none=True) + + if asset_proxy_url: + origin = deepcopy(feature["assets"][k]) + quoted_key = quote(k) + feature["assets"][k]["href"] = asset_proxy_url + "/" + quoted_key + + origin_href = origin.get("href") + if ( + settings.keep_origin_url + and origin_href + and not origin_href.startswith(tuple(settings.origin_url_blacklist)) + ): + feature["assets"][k]["alternate"] = {"origin": origin} + + # TODO: remove downloadLink asset after EODAG assets rework + if download_link := product.properties.get("eodag:download_link"): + origin_href = download_link + if asset_proxy_url: + download_link = asset_proxy_url + "/downloadLink" + + mime_type = guess_file_type(origin_href) or "application/octet-stream" + + feature["assets"]["downloadLink"] = { + "title": "Download link", + "href": download_link, + # TODO: download link is not always a ZIP archive + "type": mime_type, + } + + if settings.keep_origin_url and not origin_href.startswith(tuple(settings.origin_url_blacklist)): + feature["assets"]["downloadLink"]["alternate"] = { + "origin": { + "title": "Origin asset link", + "href": origin_href, + # TODO: download link is not always a ZIP archive + "type": mime_type, + }, + } + + feature_model = model.model_validate( + { + **product.properties, + **{"federation:backends": [product.provider], "storage:tier": product.properties.get("order:status")}, + } + ) + stac_extensions.update(feature_model.get_conformance_classes()) + + # filter properties we do not want to expose + feature["properties"] = { + k: v for k, v in feature_model.model_dump(exclude_none=True).items() if not k.startswith("eodag:") + } + feature["properties"].pop("qs", None) + + feature["stac_extensions"] = list(stac_extensions) + + if extension_names and product.provider not in auto_order_whitelist: + if "CollectionOrderExtension" in extension_names and ( + not product.properties.get("eodag:order_link", False) + or feature["properties"].get("order:status", "") != "orderable" + ): + extension_names.remove("CollectionOrderExtension") + else: + extension_names = [] + + # get request body for retrieve link (if product has to be ordered) + if "eodag:order_link" in product.properties: + retrieve_body = _get_retrieve_body_for_order(product) + else: + retrieve_body = {} + + if eodag_args := getattr(request.state, "eodag_args", None): + if provider := eodag_args.get("provider", None): + retrieve_body["federation:backends"] = [provider] + + feature["links"] = ItemLinks( + collection_id=collection, + item_id=quoted_id, + retrieve_body=retrieve_body, + request=request, + ).get_links(extensions=extension_names, extra_links=feature.get("links"), request_json=request_json) + + return feature diff --git a/stac_fastapi/eodag/models/links.py b/stac_fastapi/eodag/models/links.py index edd2e3d7..4f353633 100644 --- a/stac_fastapi/eodag/models/links.py +++ b/stac_fastapi/eodag/models/links.py @@ -21,7 +21,7 @@ from urllib.parse import ParseResult, parse_qs, unquote, urlencode, urljoin, urlparse import attr -import geojson +import orjson from stac_fastapi.types.requests import get_base_url from stac_pydantic.links import Relations from stac_pydantic.shared import MimeTypes @@ -147,41 +147,38 @@ class PagingLinks(BaseLinks): def link_next(self) -> Optional[dict[str, Any]]: """Create link for next page.""" - if self.next is not None: - method = self.request.method - federation_backend_dict = ( - {"query": {"federation:backends": {"eq": self.federation_backend}}} if self.federation_backend else {} + if self.next is None: + return None + + method = self.request.method + federation_filter = ( + {"query": {"federation:backends": {"eq": self.federation_backend}}} if self.federation_backend else {} + ) + + link = { + "rel": Relations.next.value, + "type": MimeTypes.geojson.value, + "method": method, + "title": "Next page", + } + + if method == "GET": + params = {"token": [str(self.next)]} + if "query" in self.request.query_params: + existing_query = orjson.loads(self.request.query_params["query"]) + combined_query = {**existing_query, **federation_filter.get("query", {})} + params["query"] = [orjson.dumps(combined_query)] + link["href"] = merge_params(self.url, params) + + if method == "POST": + post_body = update_nested_dict( + self.request.state.postbody, + {"token": self.next, **federation_filter}, ) - if method == "GET": - params_update_dict: dict[str, list[str]] = {"token": [str(self.next)]} - if "query" in self.request.query_params: - params_update_dict["query"] = [ - geojson.dumps( - geojson.loads(self.request.query_params["query"]) | federation_backend_dict["query"] - ) - ] - href = merge_params(self.url, params_update_dict) - return { - "rel": Relations.next.value, - "type": MimeTypes.geojson.value, - "method": method, - "href": href, - "title": "Next page", - } - if method == "POST": - post_body = update_nested_dict( - self.request.state.postbody, {"token": self.next} | federation_backend_dict - ) - return { - "rel": Relations.next, - "type": MimeTypes.geojson, - "method": method, - "href": f"{self.request.url}", - "body": post_body, - "title": "Next page", - } + link["href"] = str(self.request.url) + link["body"] = post_body - return None + return link @attr.s diff --git a/stac_fastapi/eodag/models/stac_metadata.py b/stac_fastapi/eodag/models/stac_metadata.py index 54a6aa7b..9b22afb0 100644 --- a/stac_fastapi/eodag/models/stac_metadata.py +++ b/stac_fastapi/eodag/models/stac_metadata.py @@ -20,10 +20,8 @@ from collections.abc import Callable from datetime import datetime as dt from typing import Any, ClassVar, Optional, Union, cast -from urllib.parse import quote, unquote_plus, urlparse import attr -import geojson # type: ignore from fastapi import Request from pydantic import ( AliasChoices, @@ -34,24 +32,13 @@ ) from pydantic._internal._model_construction import ModelMetaclass from pydantic.fields import FieldInfo -from stac_fastapi.types.errors import NotFoundError -from stac_fastapi.types.requests import get_base_url -from stac_fastapi.types.stac import Item -from stac_pydantic.api.extensions.sort import SortDirections, SortExtension -from stac_pydantic.api.version import STAC_API_VERSION from stac_pydantic.item import ItemProperties -from stac_pydantic.shared import Asset, Provider +from stac_pydantic.shared import Provider from typing_extensions import Self -from eodag.api.product._product import EOProduct -from eodag.api.product.metadata_mapping import OFFLINE_STATUS, ONLINE_STATUS -from eodag.utils import deepcopy, guess_file_type -from stac_fastapi.eodag.config import Settings, get_settings -from stac_fastapi.eodag.errors import MisconfiguredError from stac_fastapi.eodag.extensions.stac import ( BaseStacExtension, ) -from stac_fastapi.eodag.models.links import ItemLinks class CommonStacMetadata(ItemProperties): @@ -227,165 +214,6 @@ def get_federation_backend_dict(request: Request, provider: str) -> dict[str, An } -def _get_retrieve_body_for_order(product: EOProduct) -> dict[str, Any]: - """returns the body of the request used to order a product""" - parts = urlparse(product.properties["eodag:order_link"]) - keys = ["request", "inputs", "location"] # keys used by different providers - request_dict = geojson.loads(parts.query) - retrieve_body = None - for key in keys: - if key in request_dict: - retrieve_body = request_dict[key] - if isinstance(retrieve_body, str): # order link is quoted json or url - try: - retrieve_body = geojson.loads(unquote_plus(retrieve_body)) - except ValueError: # string is a url not a geojson -> no body required - retrieve_body = {} - elif not isinstance(retrieve_body, dict): - raise MisconfiguredError("order_link must include a dict with key request, inputs or location") - return retrieve_body - - -def create_stac_item( - product: EOProduct, - model: type[CommonStacMetadata], - extension_is_enabled: Callable[[str], bool], - request: Request, - extension_names: Optional[list[str]], - request_json: Optional[Any] = None, -) -> Item: - """Create a STAC item from an EODAG product""" - if product.collection is None: - raise NotFoundError("A STAC item can not be created from an EODAG EOProduct without collection") - - settings: Settings = get_settings() - - collection = request.app.state.dag.collections_config.source.get(product.collection, {}).get( - "alias", product.collection - ) - - feature = Item( - type="Feature", - assets={}, - id=product.properties["id"], - geometry=product.geometry.__geo_interface__, - bbox=product.geometry.bounds, - collection=collection, - stac_version=STAC_API_VERSION, - ) - - stac_extensions: set[str] = set() - - download_base_url = settings.download_base_url - if not download_base_url: - download_base_url = get_base_url(request) - - quoted_id = quote(feature["id"]) - asset_proxy_url = ( - (download_base_url + f"data/{product.provider}/{collection}/{quoted_id}") - if extension_is_enabled("DataDownload") - else None - ) - - settings = get_settings() - auto_order_whitelist = settings.auto_order_whitelist - if product.provider in auto_order_whitelist: - # a product from a whitelisted federation backend is considered as online - product.properties["order:status"] = ONLINE_STATUS - - # create assets only if product is not offline - if ( - product.properties.get("order:status", ONLINE_STATUS) != OFFLINE_STATUS - or product.provider in auto_order_whitelist - ): - for k, v in product.assets.items(): - # TODO: download extension with origin link (make it optional ?) - asset_model = Asset.model_validate(v) - feature["assets"][k] = asset_model.model_dump(exclude_none=True) - - if asset_proxy_url: - origin = deepcopy(feature["assets"][k]) - quoted_key = quote(k) - feature["assets"][k]["href"] = asset_proxy_url + "/" + quoted_key - - origin_href = origin.get("href") - if ( - settings.keep_origin_url - and origin_href - and not origin_href.startswith(tuple(settings.origin_url_blacklist)) - ): - feature["assets"][k]["alternate"] = {"origin": origin} - - # TODO: remove downloadLink asset after EODAG assets rework - if download_link := product.properties.get("eodag:download_link"): - origin_href = download_link - if asset_proxy_url: - download_link = asset_proxy_url + "/downloadLink" - - mime_type = guess_file_type(origin_href) or "application/octet-stream" - - feature["assets"]["downloadLink"] = { - "title": "Download link", - "href": download_link, - # TODO: download link is not always a ZIP archive - "type": mime_type, - } - - if settings.keep_origin_url and not origin_href.startswith(tuple(settings.origin_url_blacklist)): - feature["assets"]["downloadLink"]["alternate"] = { - "origin": { - "title": "Origin asset link", - "href": origin_href, - # TODO: download link is not always a ZIP archive - "type": mime_type, - }, - } - - feature_model = model.model_validate( - { - **product.properties, - **{"federation:backends": [product.provider], "storage:tier": product.properties.get("order:status")}, - } - ) - stac_extensions.update(feature_model.get_conformance_classes()) - - # filter properties we do not want to expose - feature["properties"] = { - k: v for k, v in feature_model.model_dump(exclude_none=True).items() if not k.startswith("eodag:") - } - feature["properties"].pop("qs", None) - - feature["stac_extensions"] = list(stac_extensions) - - if extension_names and product.provider not in auto_order_whitelist: - if "CollectionOrderExtension" in extension_names and ( - not product.properties.get("eodag:order_link", False) - or feature["properties"].get("order:status", "") != "orderable" - ): - extension_names.remove("CollectionOrderExtension") - else: - extension_names = [] - - # get request body for retrieve link (if product has to be ordered) - if "eodag:order_link" in product.properties: - retrieve_body = _get_retrieve_body_for_order(product) - else: - retrieve_body = {} - - if eodag_args := getattr(request.state, "eodag_args", None): - if provider := eodag_args.get("provider", None): - retrieve_body["federation:backends"] = [provider] - - feature["links"] = ItemLinks( - collection_id=collection, - item_id=quoted_id, - retrieve_body=retrieve_body, - request=request, - ).get_links(extensions=extension_names, extra_links=feature.get("links"), request_json=request_json) - - return feature - - def _get_conformance_classes(self) -> list[str]: """Extract list of conformance classes from set fields metadata""" conformance_classes: set[str] = set() @@ -408,16 +236,3 @@ def _get_conformance_classes(self) -> list[str]: conformance_classes.add(c) return list(conformance_classes) - - -def get_sortby_to_post(get_sortby: Optional[list[str]]) -> Optional[list[SortExtension]]: - """Convert sortby filter parameter GET syntax to POST syntax""" - if not get_sortby: - return None - post_sortby: list[SortExtension] = [] - for sortby_param in get_sortby: - sortby_param = sortby_param.strip() - direction = "desc" if sortby_param.startswith("-") else "asc" - field = sortby_param.lstrip("+-") - post_sortby.append(SortExtension(field=field, direction=SortDirections(direction))) - return post_sortby