From 72a8df9510895405e183ecb2b41df45ce1d4f089 Mon Sep 17 00:00:00 2001 From: Oleksii Vykaliuk Date: Fri, 20 Dec 2024 10:29:19 +0100 Subject: [PATCH] feat: STAC Render Extension support (#1038) * Render Extension Render extension started during STAC render sprint in SatSummit Lisbon 2024. - listing (or showing to please Vincent) Please contribute to complete the feature to - generate the final XYZ link for rendering following the rules in STAC extensions - add a dedicated endpoint for render XYZ * feat(stac): add render extenstions support * remove unnecessary item.json file * Rework the response structure to include links. * rename renderExtension -> stacRenderExtension * add tests for stacRenderExtension * add test for extra param * docs and docstrings * fix typing in python 3.8 * Move out query params validation * edits * update changelog --------- Co-authored-by: Emmanuel Mathot Co-authored-by: vincentsarago --- CHANGES.md | 1 + docs/src/advanced/Extensions.md | 4 + .../application/tests/routes/test_stac.py | 2 +- .../application/titiler/application/main.py | 2 + src/titiler/core/tests/test_utils.py | 67 ++++ src/titiler/core/titiler/core/utils.py | 54 ++- .../tests/fixtures/render_item.json | 324 ++++++++++++++++++ .../extensions/tests/test_stac_render.py | 80 +++++ .../extensions/titiler/extensions/__init__.py | 1 + .../extensions/titiler/extensions/render.py | 179 ++++++++++ 10 files changed, 712 insertions(+), 2 deletions(-) create mode 100644 src/titiler/core/tests/test_utils.py create mode 100644 src/titiler/extensions/tests/fixtures/render_item.json create mode 100644 src/titiler/extensions/tests/test_stac_render.py create mode 100644 src/titiler/extensions/titiler/extensions/render.py diff --git a/CHANGES.md b/CHANGES.md index 02fbe2337..3909cc6cd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -26,6 +26,7 @@ ### titiler.extensions * use `factory.render_func` as render function in `wmsExtension` endpoints +* add `stacRenderExtension` which adds two endpoints: `/renders` (lists all renders) and `/renders/` (render metadata and links) (author @alekzvik, https://github.com/developmentseed/titiler/pull/1038) ### Misc diff --git a/docs/src/advanced/Extensions.md b/docs/src/advanced/Extensions.md index 531d612b0..bc5f128c2 100644 --- a/docs/src/advanced/Extensions.md +++ b/docs/src/advanced/Extensions.md @@ -55,6 +55,10 @@ class FactoryExtension(metaclass=abc.ABCMeta): - Goal: adds a `/wms` endpoint to support OGC WMS specification (`GetCapabilities` and `GetMap`) +#### stacRenderExtenstion + +- Goal: adds `/render` and `/render/{render_id}` endpoints which return the contents of [STAC render extension](https://github.com/stac-extensions/render) and links to tileset.json and WMTS service + ## How To ### Use extensions diff --git a/src/titiler/application/tests/routes/test_stac.py b/src/titiler/application/tests/routes/test_stac.py index f159a40c7..e2f0f91d8 100644 --- a/src/titiler/application/tests/routes/test_stac.py +++ b/src/titiler/application/tests/routes/test_stac.py @@ -1,4 +1,4 @@ -"""test /COG endpoints.""" +"""test /stac endpoints.""" from typing import Dict from unittest.mock import patch diff --git a/src/titiler/application/titiler/application/main.py b/src/titiler/application/titiler/application/main.py index 1a0518c82..cd60d50ad 100644 --- a/src/titiler/application/titiler/application/main.py +++ b/src/titiler/application/titiler/application/main.py @@ -34,6 +34,7 @@ cogValidateExtension, cogViewerExtension, stacExtension, + stacRenderExtension, stacViewerExtension, ) from titiler.mosaic.errors import MOSAIC_STATUS_CODES @@ -123,6 +124,7 @@ def validate_access_token(access_token: str = Security(api_key_query)): router_prefix="/stac", extensions=[ stacViewerExtension(), + stacRenderExtension(), ], ) diff --git a/src/titiler/core/tests/test_utils.py b/src/titiler/core/tests/test_utils.py new file mode 100644 index 000000000..1ea5832f3 --- /dev/null +++ b/src/titiler/core/tests/test_utils.py @@ -0,0 +1,67 @@ +"""Test utils.""" + +from titiler.core.dependencies import BidxParams +from titiler.core.utils import deserialize_query_params, get_dependency_query_params + + +def test_get_dependency_params(): + """Test dependency filtering from query params.""" + + # invalid + values, err = get_dependency_query_params( + dependency=BidxParams, params={"bidx": ["invalid type"]} + ) + assert values == {} + assert err + assert err == [ + { + "input": "invalid type", + "loc": ( + "query", + "bidx", + 0, + ), + "msg": "Input should be a valid integer, unable to parse string as an integer", + "type": "int_parsing", + }, + ] + + # not in dep + values, err = get_dependency_query_params( + dependency=BidxParams, params={"not_in_dep": "no error, no value"} + ) + assert values == {"indexes": None} + assert not err + + # valid + values, err = get_dependency_query_params( + dependency=BidxParams, params={"bidx": [1, 2, 3]} + ) + assert values == {"indexes": [1, 2, 3]} + assert not err + + # valid and not in dep + values, err = get_dependency_query_params( + dependency=BidxParams, + params={"bidx": [1, 2, 3], "other param": "to be filtered out"}, + ) + assert values == {"indexes": [1, 2, 3]} + assert not err + + +def test_deserialize_query_params(): + """Test deserialize_query_params.""" + # invalid + res, err = deserialize_query_params( + dependency=BidxParams, params={"bidx": ["invalid type"]} + ) + print(res) + assert res == BidxParams(indexes=None) + assert err + + # valid + res, err = deserialize_query_params( + dependency=BidxParams, params={"not_in_dep": "no error, no value", "bidx": [1]} + ) + assert res == BidxParams(indexes=[1]) + assert not err diff --git a/src/titiler/core/titiler/core/utils.py b/src/titiler/core/titiler/core/utils.py index 3ead5aa85..a6de28ea7 100644 --- a/src/titiler/core/titiler/core/utils.py +++ b/src/titiler/core/titiler/core/utils.py @@ -1,9 +1,12 @@ """titiler.core utilities.""" import warnings -from typing import Any, Optional, Sequence, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, TypeVar, Union +from urllib.parse import urlencode import numpy +from fastapi.datastructures import QueryParams +from fastapi.dependencies.utils import get_dependant, request_params_to_args from geojson_pydantic.geometries import MultiPolygon, Polygon from rasterio.dtypes import dtype_ranges from rio_tiler.colormap import apply_cmap @@ -131,3 +134,52 @@ def bounds_to_geometry(bounds: BBox) -> Union[Polygon, MultiPolygon]: coordinates=[pl.coordinates, pr.coordinates], ) return Polygon.from_bounds(*bounds) + + +T = TypeVar("T") + +ValidParams = Dict[str, Any] +Errors = List[Any] + + +def get_dependency_query_params( + dependency: Callable, + params: Dict, +) -> Tuple[ValidParams, Errors]: + """Check QueryParams for Query dependency. + + 1. `get_dependant` is used to get the query-parameters required by the `callable` + 2. we use `request_params_to_args` to construct arguments needed to call the `callable` + 3. we call the `callable` and catch any errors + + Important: We assume the `callable` in not a co-routine. + """ + dep = get_dependant(path="", call=dependency) + return request_params_to_args( + dep.query_params, QueryParams(urlencode(params, doseq=True)) + ) + + +def deserialize_query_params( + dependency: Callable[..., T], params: Dict +) -> Tuple[T, Errors]: + """Deserialize QueryParams for given dependency. + + Parse params as query params and deserialize with dependency. + + Important: We assume the `callable` in not a co-routine. + """ + values, errors = get_dependency_query_params(dependency, params) + return dependency(**values), errors + + +def extract_query_params(dependencies, params) -> Tuple[ValidParams, Errors]: + """Extract query params given list of dependencies.""" + values = {} + errors = [] + for dep in dependencies: + dep_values, dep_errors = deserialize_query_params(dep, params) + if dep_values: + values.update(dep_values) + errors += dep_errors + return values, errors diff --git a/src/titiler/extensions/tests/fixtures/render_item.json b/src/titiler/extensions/tests/fixtures/render_item.json new file mode 100644 index 000000000..5f0b93fa3 --- /dev/null +++ b/src/titiler/extensions/tests/fixtures/render_item.json @@ -0,0 +1,324 @@ +{ + "type": "Feature", + "stac_version": "1.0.0", + "stac_extensions": [ + "https://stac-extensions.github.io/eo/v1.0.0/schema.json", + "https://stac-extensions.github.io/projection/v1.0.0/schema.json", + "https://stac-extensions.github.io/view/v1.0.0/schema.json", + "https://stac-extensions.github.io/render/v2.0.0/schema.json", + "https://stac-extensions.github.io/virtual-assets/v1.0.0/schema.json", + "https://stac-extensions.github.io/web-map-links/v1.2.0/schema.json" + ], + "id": "LC08_L1TP_044033_20210305_20210312_01_T1", + "properties": { + "gsd": 30, + "platform": "LANDSAT_8", + "instruments": [ + "OLI", + "TIRS" + ], + "eo:cloud_cover": 7.41, + "proj:epsg": 32610, + "view:sun_azimuth": 149.10910644, + "view:sun_elevation": 40.48243563, + "view:off_nadir": 0.001, + "landsat:scene_id": "LC80440332021064LGN00", + "landsat:processing_level": "L1TP", + "landsat:collection_number": "01", + "landsat:collection_category": "T1", + "landsat:cloud_cover_land": 7.4, + "landsat:wrs_path": "44", + "landsat:wrs_row": "33", + "datetime": "2021-03-05T18:45:37.619485Z", + "created": "2021-03-16T01:40:56.703Z", + "updated": "2021-03-16T01:40:56.703Z", + "renders": { + "thumbnail": { + "title": "Thumbnail", + "assets": [ + "B4", + "B3", + "B2" + ], + "rescale": [ + [ + 0, + 150 + ] + ], + "colormap_name": "rainbow", + "resampling": "bilinear", + "bidx": [ + 1 + ], + "width": 1024, + "height": 1024, + "bands": [ + "B4", + "B3", + "B2" + ] + }, + "ndvi": { + "title": "Normalized Difference Vegetation Index", + "assets": [ + "ndvi" + ], + "resampling": "average", + "colormap_name": "ylgn", + "extra_param": "that titiler does not know" + } + } + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -122.49680286164214, + 39.958062660227306 + ], + [ + -120.31547276090922, + 39.578858170656 + ], + [ + -120.82135075676177, + 37.82701417652536 + ], + [ + -122.9993441554352, + 38.2150173967007 + ], + [ + -122.49680286164214, + 39.958062660227306 + ] + ] + ] + }, + "links": [ + { + "href": "https://maps.example.com/xyz/{z}/{x}/{y}.png", + "rel": "xyz", + "type": "image/png", + "title": "RGB composite visualized through a XYZ" + }, + { + "rel": "xyz", + "type": "image/png", + "title": "NDVI", + "href": "https://api.cogeo.xyz/stac/preview.png?url=https://raw.githubusercontent.com/stac-extensions/raster/main/examples/item-landsat8.json&expression=(B5–B4)/(B5+B4)&max_size=512&width=512&resampling_method=average&rescale=-1,1&color_map=ylgn&return_mask=true", + "render": "ndvi" + }, + { + "rel": "collection", + "href": "https://landsat-stac.s3.amazonaws.com/collections/landsat-8-l1.json", + "type": "application/json", + "title": "The full collection" + } + ], + "assets": { + "index": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/index.html", + "type": "application/html", + "title": "HTML Page" + }, + "ANG": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_ANG.txt", + "type": "text/plain", + "title": "ANG Metadata", + "roles": [ + "metadata" + ] + }, + "MTL": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_MTL.txt", + "type": "text/plain", + "title": "MTL Metadata", + "roles": [ + "metadata" + ] + }, + "BQA": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_BQA.TIF", + "type": "image/tiff; application=geotiff", + "title": "Quality Band", + "roles": [ + "quality" + ] + }, + "B1": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B1.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B1", + "common_name": "coastal", + "center_wavelength": 0.48, + "full_width_half_max": 0.02 + } + ] + }, + "B2": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B2.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B2", + "common_name": "blue", + "center_wavelength": 0.44, + "full_width_half_max": 0.06 + } + ] + }, + "B3": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B3.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B3", + "common_name": "green", + "center_wavelength": 0.56, + "full_width_half_max": 0.06 + } + ] + }, + "B4": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B4.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B4", + "common_name": "red", + "center_wavelength": 0.65, + "full_width_half_max": 0.04 + } + ] + }, + "B5": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B5.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B5", + "common_name": "nir", + "center_wavelength": 0.86, + "full_width_half_max": 0.03 + } + ] + }, + "B6": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B6.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B6", + "common_name": "swir16", + "center_wavelength": 1.6, + "full_width_half_max": 0.08 + } + ] + }, + "B7": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B7.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B7", + "common_name": "swir22", + "center_wavelength": 2.2, + "full_width_half_max": 0.2 + } + ] + }, + "B8": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B8.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B8", + "common_name": "pan", + "center_wavelength": 0.59, + "full_width_half_max": 0.18 + } + ], + "gsd": 15 + }, + "B9": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B9.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B9", + "common_name": "cirrus", + "center_wavelength": 1.37, + "full_width_half_max": 0.02 + } + ] + }, + "B10": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B10.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B10", + "common_name": "lwir11", + "center_wavelength": 10.9, + "full_width_half_max": 0.8 + } + ], + "gsd": 100 + }, + "B11": { + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1/LC08_L1TP_044033_20210305_20210312_01_T1_B11.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "eo:bands": [ + { + "name": "B11", + "common_name": "lwir12", + "center_wavelength": 12, + "full_width_half_max": 1 + } + ], + "gsd": 100 + }, + "ndvi": { + "roles": [ + "virtual", + "data", + "index" + ], + "type": "image/vnd.stac.geotiff; cloud-optimized=true", + "href": "https://landsat-pds.s3.us-west-2.amazonaws.com/c1/L8/044/033/LC08_L1TP_044033_20210305_20210312_01_T1#/assets/NDVI", + "vrt:hrefs": [ + { + "key": "B4", + "href": "#/assets/B4" + }, + { + "key": "B5", + "href": "#/assets/B5" + } + ], + "title": "Normalized Difference Vegetation Index", + "vrt:algorithm": "band_arithmetic", + "vrt:algorithm_opts": { + "expression": "(B05-B04)/(B05+B04)", + "rescale": [ + [ + -1, + 1 + ] + ] + } + } + }, + "bbox": [ + -123.00234, + 37.82405, + -120.31321, + 39.95894 + ], + "collection": "landsat-8-l1-c1" +} \ No newline at end of file diff --git a/src/titiler/extensions/tests/test_stac_render.py b/src/titiler/extensions/tests/test_stac_render.py new file mode 100644 index 000000000..7fa1ce5df --- /dev/null +++ b/src/titiler/extensions/tests/test_stac_render.py @@ -0,0 +1,80 @@ +"""Test STAC Render extension.""" + +import os +from urllib.parse import urlencode + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from rio_tiler.io import STACReader + +from titiler.core.factory import MultiBaseTilerFactory +from titiler.extensions.render import stacRenderExtension + +stac_item = os.path.join(os.path.dirname(__file__), "fixtures", "render_item.json") + + +def test_stacExtension(): + """Test stacExtension class.""" + + stac_tiler = MultiBaseTilerFactory(reader=STACReader) + + stac_tiler_plus_stac_render = MultiBaseTilerFactory( + reader=STACReader, extensions=[stacRenderExtension()] + ) + # Check that we added two routes (/renders & /renders/{render_id}) + assert ( + len(stac_tiler_plus_stac_render.router.routes) + == len(stac_tiler.router.routes) + 2 + ) + + app = FastAPI() + app.include_router(stac_tiler_plus_stac_render.router) + with TestClient(app) as client: + response = client.get("/renders", params={"url": stac_item}) + assert response.status_code == 200 + body = response.json() + assert body["renders"] + assert body["links"] + + self_link = body["links"][0] + assert self_link["href"] == response.url + assert self_link["rel"] == "self" + + assert "ndvi" in body["renders"] + assert "thumbnail" in body["renders"] + + expected_params = { + "assets": ["ndvi"], + "colormap_name": "ylgn", + "resampling": "average", + "title": "Normalized Difference Vegetation Index", + "extra_param": "that titiler does not know", + } + assert body["renders"]["ndvi"]["params"] == expected_params + + links = body["renders"]["ndvi"]["links"] + assert len(links) == 3 + + stac_item_param = urlencode({"url": stac_item}) + additional_params = "title=Normalized+Difference+Vegetation+Index&assets=ndvi&resampling=average&colormap_name=ylgn&extra_param=that+titiler+does+not+know" + hrefs = {link["href"] for link in links} + expected_hrefs = { + f"http://testserver/renders/ndvi?{stac_item_param}", + f"http://testserver/{{tileMatrixSetId}}/WMTSCapabilities.xml?{stac_item_param}&{additional_params}", + f"http://testserver/{{tileMatrixSetId}}/tilejson.json?{stac_item_param}&{additional_params}", + } + assert hrefs == expected_hrefs + + response = client.get("/renders/unknown", params={"url": stac_item}) + assert response.status_code == 404 + body = response.json() + assert body == {"detail": "Render not found"} + + response = client.get("/renders/ndvi", params={"url": stac_item}) + assert response.status_code == 200 + body = response.json() + assert body["params"] + assert body["links"] + hrefs = {link["href"] for link in links} + assert hrefs == expected_hrefs + assert body["params"] == expected_params diff --git a/src/titiler/extensions/titiler/extensions/__init__.py b/src/titiler/extensions/titiler/extensions/__init__.py index bffec1559..d43f89ce8 100644 --- a/src/titiler/extensions/titiler/extensions/__init__.py +++ b/src/titiler/extensions/titiler/extensions/__init__.py @@ -3,6 +3,7 @@ __version__ = "0.19.2" from .cogeo import cogValidateExtension # noqa +from .render import stacRenderExtension # noqa from .stac import stacExtension # noqa from .viewer import cogViewerExtension, stacViewerExtension # noqa from .wms import wmsExtension # noqa diff --git a/src/titiler/extensions/titiler/extensions/render.py b/src/titiler/extensions/titiler/extensions/render.py new file mode 100644 index 000000000..0bfe09884 --- /dev/null +++ b/src/titiler/extensions/titiler/extensions/render.py @@ -0,0 +1,179 @@ +"""STAC Render Extension. + +Implements support for reading and applying Item level render extension. +See: https://github.com/stac-extensions/render +""" + +from typing import Dict, List, Optional +from urllib.parse import urlencode + +from attrs import define +from fastapi import Depends, HTTPException, Path, Request +from pydantic import BaseModel +from typing_extensions import Annotated + +from titiler.core.factory import FactoryExtension, MultiBaseTilerFactory +from titiler.core.models.OGC import Link +from titiler.core.utils import extract_query_params + + +class RenderItem(BaseModel, extra="allow"): + """Render item for stac render extension.""" + + assets: List[str] + title: Optional[str] = None + rescale: Optional[List[Annotated[List[float], 2]]] = None + nodata: Optional[float] = None + colormap_name: Optional[str] = None + colormap: Optional[Dict] = None + color_formula: Optional[str] = None + resampling: Optional[str] = None + expression: Optional[str] = None + minmax_zoom: Optional[Annotated[List[int], 2]] = None + + +class RenderItemWithLinks(BaseModel): + """Same as RenderItem with url and params.""" + + params: RenderItem + links: List[Link] + + +class RenderItemList(BaseModel): + """List of Render Items with links.""" + + renders: Dict[str, RenderItemWithLinks] + links: List[Link] + + +@define +class stacRenderExtension(FactoryExtension): + """Add /renders endpoint to a STAC TilerFactory.""" + + def register(self, factory: MultiBaseTilerFactory): + """Register endpoint to the tiler factory.""" + + def _validate_params(render: Dict) -> bool: + """Validate render related query params.""" + # List of dependencies a `/tile` URL should validate + # Note: Those dependencies should only require Query() inputs + tile_dependencies = [ + factory.reader_dependency, + factory.tile_dependency, + factory.layer_dependency, + factory.dataset_dependency, + factory.process_dependency, + # Image rendering Dependencies + factory.colormap_dependency, + factory.render_dependency, + ] + + _values, errors = extract_query_params(tile_dependencies, render) + + return errors + + def _prepare_render_item( + render_id: str, + render: Dict, + request: Request, + src_path: str, + ) -> Dict: + """Prepare single render item.""" + links = [ + { + "href": factory.url_for( + request, + "STAC Renders metadata", + render_id=render_id, + ) + + "?" + + urlencode({"url": src_path}), + "rel": "self", + "type": "application/json", + "title": f"STAC Renders metadata for {render_id}", + } + ] + + if not _validate_params(render): + query_string = urlencode({"url": src_path, **render}, doseq=True) + + links += [ + { + "href": factory.url_for( + request, + "tilejson", + tileMatrixSetId="{tileMatrixSetId}", + ) + + "?" + + query_string, + "rel": "tilesets-map", + "title": f"tilejson file for {render_id}", + "templated": True, + }, + { + "href": factory.url_for( + request, + "wmts", + tileMatrixSetId="{tileMatrixSetId}", + ) + + "?" + + query_string, + "rel": "tilesets-map", + "title": f"WMTS service for {render_id}", + "templated": True, + }, + ] + + return {"params": render, "links": links} + + @factory.router.get( + "/renders", + response_model=RenderItemList, + response_model_exclude_none=True, + name="List STAC Renders metadata", + ) + def render_list(request: Request, src_path=Depends(factory.path_dependency)): + with factory.reader(src_path) as src: + renders = src.item.properties.get("renders", {}) + + prepared_renders = { + render_id: _prepare_render_item(render_id, render, request, src_path) + for render_id, render in renders.items() + } + return { + "renders": prepared_renders, + "links": [ + { + "href": str(request.url), + "rel": "self", + "type": "application/json", + "title": "List STAC Renders metadata", + }, + ], + } + + @factory.router.get( + "/renders/{render_id}", + response_model=RenderItemWithLinks, + response_model_exclude_none=True, + name="STAC Renders metadata", + ) + def render( + request: Request, + render_id: str = Path( + description="render id", + ), + src_path=Depends(factory.path_dependency), + ): + with factory.reader(src_path) as src: + renders = src.item.properties.get("renders", {}) + + if render_id not in renders: + raise HTTPException(status_code=404, detail="Render not found") + + return _prepare_render_item( + render_id, + renders[render_id], + request, + src_path, + )