Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: STAC Render Extension support #1038

Merged
Prev Previous commit
Next Next commit
Move out query params validation
  • Loading branch information
alekzvik committed Dec 2, 2024
commit 8dbdcf636c59c07cf908d9908ddea556ec7e61c3
68 changes: 68 additions & 0 deletions src/titiler/core/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""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
54 changes: 53 additions & 1 deletion src/titiler/core/titiler/core/utils.py
Original file line number Diff line number Diff line change
@@ -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 rasterio.dtypes import dtype_ranges
from rio_tiler.colormap import apply_cmap
from rio_tiler.errors import InvalidDatatypeWarning
Expand Down Expand Up @@ -116,3 +119,52 @@ def render_image(
),
output_format.mediatype,
)


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))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notice the addition of QueryParams here.
The reason is that request_params_to_args expects to see the result of this function.
An example that helped me catch it is rescale param - link to def.

rescale: Annotated[
    Optional[List[str]],
    Query(...)
]

If I pass things directly from STAC renders block, it would not pass validation, since it is defined as [float]
To mitigate this, I first serialize params to the state that fastapi would receive them, and then deserialize how it would do it.

)


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
6 changes: 4 additions & 2 deletions src/titiler/extensions/tests/test_stac_render.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Test STAC Render extension."""

import os
from urllib.parse import urlencode

Expand Down Expand Up @@ -55,11 +56,12 @@ def test_stacExtension():
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}&assets=ndvi&resampling_method=average&colormap_name=ylgn",
f"http://testserver/{{tileMatrixSetId}}/tilejson.json?{stac_item_param}&assets=ndvi&resampling_method=average&colormap_name=ylgn",
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

Expand Down
113 changes: 57 additions & 56 deletions src/titiler/extensions/titiler/extensions/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@

from attrs import define
from fastapi import Depends, HTTPException, Path, Request
from fastapi.dependencies.utils import get_dependant, request_params_to_args
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"):
Expand Down Expand Up @@ -53,15 +53,12 @@ class stacRenderExtension(FactoryExtension):
def register(self, factory: MultiBaseTilerFactory):
"""Register endpoint to the tiler factory."""

def _prepare_query_string(render: Dict, src_path: str) -> str:
"""Prepare render related query params.

Validates and filters query params that titiler can understand.
If titiler does not support parameter, it will be ignored.
"""
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.layer_dependency,
factory.dataset_dependency,
# Image rendering Dependencies
Expand All @@ -71,64 +68,65 @@ def _prepare_query_string(render: Dict, src_path: str) -> str:
factory.render_dependency,
]

query = {"url": src_path}
for dependency in tile_dependencies:
dep = get_dependant(path="", call=dependency)
if dep.query_params:
# call the dependency with the query-parameters values
query_values, _errors = request_params_to_args(
dep.query_params, render
)
_ = dependency(**query_values)
query.update(query_values)
return urlencode(
{key: value for key, value in query.items() if value is not None},
doseq=True,
)
_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."""
query_string = _prepare_query_string(render, src_path)
links = [
{
"href": factory.url_for(
request,
"Show STAC render",
"STAC Renders metadata",
render_id=render_id,
)
+ "?"
+ urlencode({"url": src_path}),
"rel": "self",
"type": "application/json",
"title": f"{render_id} render item",
},
{
"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,
},
"title": f"STAC Renders metadata for {render_id}",
}
]
errors = _validate_params(render)

if not errors:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

errors here is a list of all validation errors for STAC render block.
I decided to exclude links if there are errors, as I know they are invalid and will fail if you follow the link.
An alternative is to fail louder, e.g. with HTTP 400 and error details. Let me know if you prefer it.

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,
Expand All @@ -139,11 +137,12 @@ def _prepare_render_item(
"/renders",
response_model=RenderItemList,
response_model_exclude_none=True,
name="List STAC renders",
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()
Expand All @@ -155,7 +154,7 @@ def render_list(request: Request, src_path=Depends(factory.path_dependency)):
"href": str(request.url),
"rel": "self",
"type": "application/json",
"title": "List Render Items",
"title": "List STAC Renders metadata",
},
],
}
Expand All @@ -164,7 +163,7 @@ def render_list(request: Request, src_path=Depends(factory.path_dependency)):
"/renders/{render_id}",
response_model=RenderItemWithLinks,
response_model_exclude_none=True,
name="Show STAC render",
name="STAC Renders metadata",
)
def render(
request: Request,
Expand All @@ -175,8 +174,10 @@ def render(
):
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")
render = renders[render_id]

return _prepare_render_item(render_id, render, request, src_path)
if render_id not in renders:
raise HTTPException(status_code=404, detail="Render not found")

render = renders[render_id]

return _prepare_render_item(render_id, render, request, src_path)