diff --git a/CHANGES.md b/CHANGES.md index cef59ac8e..3251a30bf 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -12,6 +12,7 @@ * renamed `OptionalHeaders`, `MimeTypes` and `ImageDrivers` enums to the singular form. (https://github.com/developmentseed/titiler/pull/258) * renamed `MimeType` to `MediaType` (https://github.com/developmentseed/titiler/pull/258) * add `ColorMapParams` dependency to ease the creation of custom colormap dependency (https://github.com/developmentseed/titiler/pull/252) +* renamed `PathParams` to `DatasetPathParams` and also made it a simple callable (https://github.com/developmentseed/titiler/pull/260) ## 0.1.0 (2021-02-17) diff --git a/docs/concepts/customization.md b/docs/concepts/customization.md index 853248a09..a86853b92 100644 --- a/docs/concepts/customization.md +++ b/docs/concepts/customization.md @@ -177,7 +177,7 @@ class STACTiler(TilerFactory): stac = STACTiler(router_prefix="stac") ``` -### Custom PathParams for `path_dependency` +### Custom DatasetPathParams for `path_dependency` One common customization could be to create your own `path_dependency` (used in all endpoints). @@ -195,26 +195,18 @@ MOSAIC_BACKEND = os.getenv("TITILER_MOSAIC_BACKEND") MOSAIC_HOST = os.getenv("TITILER_MOSAIC_HOST") -@dataclass -class PathParams(DefaultDependency): - """Create dataset path from args""" - +def MosaicPathParams( mosaic: str = Query(..., description="mosaic name") +) -> str: + """Create dataset path from args""" + # mosaic name should be in form of `{user}.{layername}` + if not re.match(self.mosaic, r"^[a-zA-Z0-9-_]{1,32}\.[a-zA-Z0-9-_]{1,32}$"): + raise HTTPException( + status_code=400, + detail=f"Invalid mosaic name {self.mosaic}.", + ) - # We need url to match default PathParams signature - # Because we set `init=False` those params won't appear in OpenAPI docs. - url: Optional[str] = field(init=False, default=None) - - def __post_init__(self,): - """Define dataset URL.""" - # mosaic name should be in form of `{user}.{layername}` - if not re.match(self.mosaic, r"^[a-zA-Z0-9-_]{1,32}\.[a-zA-Z0-9-_]{1,32}$"): - raise HTTPException( - status_code=400, - detail=f"Invalid mosaic name {self.mosaic}.", - ) - - self.url = f"{MOSAIC_BACKEND}{MOSAIC_HOST}/{self.mosaic}.json.gz" + return f"{MOSAIC_BACKEND}{MOSAIC_HOST}/{self.mosaic}.json.gz" ``` ### Custom TMS @@ -335,7 +327,7 @@ class CustomMosaicFactory(MosaicTilerFactory): src_path = self.path_dependency(body.url) with rasterio.Env(**self.gdal_config): with self.reader( - src_path.url, mosaic_def=mosaic, reader=self.dataset_reader + src_path, mosaic_def=mosaic, reader=self.dataset_reader ) as mosaic: try: mosaic.write(overwrite=body.overwrite) @@ -358,7 +350,7 @@ class CustomMosaicFactory(MosaicTilerFactory): """Update an existing MosaicJSON""" src_path = self.path_dependency(body.url) with rasterio.Env(**self.gdal_config): - with self.reader(src_path.url, reader=self.dataset_reader) as mosaic: + with self.reader(src_path, reader=self.dataset_reader) as mosaic: features = get_footprints(body.files, max_threads=body.max_threads) try: mosaic.update(features, add_first=body.add_first, quiet=True) diff --git a/docs/concepts/dependencies.md b/docs/concepts/dependencies.md index 4e6e4e4b6..75796ebd2 100644 --- a/docs/concepts/dependencies.md +++ b/docs/concepts/dependencies.md @@ -58,11 +58,11 @@ The `factories` allow users to set multiple default dependencies. Here is the li * **path_dependency**: Set dataset path (url). ```python - @dataclass - class PathParams(DefaultDependency): - """Create dataset path from args""" - + def DatasetPathParams( url: str = Query(..., description="Dataset URL") + ) -> str: + """Create dataset path from args""" + return url ``` * **tms_dependency**: The TMS dependency sets the available TMS for a tile endpoint. diff --git a/docs/examples/Create_CustomSentinel2Tiler.ipynb b/docs/examples/Create_CustomSentinel2Tiler.ipynb index 73ff61b64..59f24b1d0 100644 --- a/docs/examples/Create_CustomSentinel2Tiler.ipynb +++ b/docs/examples/Create_CustomSentinel2Tiler.ipynb @@ -68,137 +68,32 @@ ">>> pip install titiler rio-tiler-pds\n", "\n", "\"\"\"\n", - "from dataclasses import dataclass, field\n", - "from typing import Dict, Type, Optional, Sequence\n", + "from titiler.endpoints.factory import MultiBandTilerFactory, MosaicTilerFactory\n", + "from titiler.dependencies import BandsExprParams\n", "\n", - "from titiler.endpoints.factory import TilerFactory, MosaicTilerFactory\n", - "from titiler.dependencies import DefaultDependency\n", - "\n", - "\n", - "from rio_tiler.models import Info, Metadata\n", "from rio_tiler_pds.sentinel.aws import S2COGReader\n", "from rio_tiler_pds.sentinel.utils import s2_sceneid_parser\n", "\n", - "from fastapi import FastAPI, Depends, Query\n", + "from fastapi import FastAPI, Query\n", "\n", "\n", - "@dataclass\n", - "class CustomPathParams:\n", + "def CustomPathParams(\n", + " sceneid: str = Query(..., description=\"Sentinel 2 Sceneid.\")\n", + "):\n", " \"\"\"Create dataset path from args\"\"\"\n", - "\n", - " sceneid: str = Query(..., description=\"Landsat 8 Sceneid.\")\n", - " scene_metadata: Dict = field(init=False)\n", - "\n", - " def __post_init__(self,):\n", - " \"\"\"Define dataset URL.\"\"\"\n", - " self.url = self.sceneid\n", - " self.scene_metadata = s2_sceneid_parser(self.sceneid)\n", - "\n", - "\n", - "def BandsParams(\n", - " bands: str = Query(\n", - " ...,\n", - " title=\"bands names\",\n", - " description=\"comma (',') delimited bands names.\",\n", - " )\n", - ") -> Sequence[str]:\n", - " \"\"\"Bands.\"\"\"\n", - " return bands.split(\",\")\n", - "\n", - "\n", - "@dataclass\n", - "class BandsExprParams(DefaultDependency):\n", - " \"\"\"Band names and Expression parameters.\"\"\"\n", - "\n", - " bands: Optional[str] = Query(\n", - " None,\n", - " title=\"bands names\",\n", - " description=\"comma (',') delimited bands names.\",\n", - " )\n", - " expression: Optional[str] = Query(\n", - " None,\n", - " title=\"Band Math expression\",\n", - " description=\"rio-tiler's band math expression.\",\n", - " )\n", - "\n", - " def __post_init__(self):\n", - " \"\"\"Post Init.\"\"\"\n", - " if self.bands is not None:\n", - " self.kwargs[\"bands\"] = self.bands.split(\",\")\n", - " if self.expression is not None:\n", - " self.kwargs[\"expression\"] = self.expression\n", - "\n", - "\n", - "@dataclass\n", - "class S2COGTiler(TilerFactory):\n", - " \"\"\"Custom Tiler Class for STAC.\"\"\"\n", - "\n", - " reader: Type[S2COGReader] = field(default=S2COGReader)\n", - "\n", - " path_dependency: Type[CustomPathParams] = CustomPathParams\n", - "\n", - " layer_dependency: Type[DefaultDependency] = BandsExprParams\n", - "\n", - " def info(self):\n", - " \"\"\"Register /info endpoint.\"\"\"\n", - "\n", - " @self.router.get(\n", - " \"/info\",\n", - " response_model=Info,\n", - " response_model_exclude={\"minzoom\", \"maxzoom\", \"center\"},\n", - " response_model_exclude_none=True,\n", - " responses={200: {\"description\": \"Return dataset's basic info.\"}},\n", - " )\n", - " def info(\n", - " src_path=Depends(self.path_dependency),\n", - " bands=Depends(BandsParams),\n", - " kwargs: Dict = Depends(self.additional_dependency),\n", - " ):\n", - " \"\"\"Return basic info.\"\"\"\n", - " with self.reader(src_path.url, **self.reader_options) as src_dst:\n", - " info = src_dst.info(bands=bands, **kwargs)\n", - " return info\n", - "\n", - " def metadata(self):\n", - " \"\"\"Register /metadata endpoint.\"\"\"\n", - "\n", - " @self.router.get(\n", - " \"/metadata\",\n", - " response_model=Metadata,\n", - " response_model_exclude={\"minzoom\", \"maxzoom\", \"center\"},\n", - " response_model_exclude_none=True,\n", - " responses={200: {\"description\": \"Return dataset's metadata.\"}},\n", - " )\n", - " def metadata(\n", - " src_path=Depends(self.path_dependency),\n", - " bands=Depends(BandsParams),\n", - " metadata_params=Depends(self.metadata_dependency),\n", - " kwargs: Dict = Depends(self.additional_dependency),\n", - " ):\n", - " \"\"\"Return metadata.\"\"\"\n", - " with self.reader(src_path.url, **self.reader_options) as src_dst:\n", - " info = src_dst.metadata(\n", - " metadata_params.pmin,\n", - " metadata_params.pmax,\n", - " bands=bands,\n", - " **metadata_params.kwargs,\n", - " **kwargs,\n", - " )\n", - " return info\n", + " assert s2_sceneid_parser(sceneid)\n", + " return sceneid\n", "\n", "\n", "app = FastAPI()\n", "\n", - "\n", - "scene_tiler = S2COGTiler(router_prefix=\"scenes\")\n", + "scene_tiler = MultiBandTilerFactory(reader=S2COGReader, path_dependency=CustomPathParams, router_prefix=\"scenes\")\n", "app.include_router(scene_tiler.router, prefix=\"/scenes\", tags=[\"scenes\"])\n", "\n", "mosaic_tiler = MosaicTilerFactory(\n", " router_prefix=\"mosaic\",\n", " dataset_reader=S2COGReader,\n", " layer_dependency=BandsExprParams,\n", - " add_update=False,\n", - " add_create=False,\n", ")\n", "app.include_router(mosaic_tiler.router, prefix=\"/mosaic\", tags=[\"mosaic\"])\n", "```" @@ -704,9 +599,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.6" + "version": "3.8.2-final" } }, "nbformat": 4, "nbformat_minor": 2 -} +} \ No newline at end of file diff --git a/tests/test_CustomPath.py b/tests/test_CustomPath.py new file mode 100644 index 000000000..1e0272130 --- /dev/null +++ b/tests/test_CustomPath.py @@ -0,0 +1,36 @@ +"""Test TiTiler Custom Path Params.""" + +import re + +from titiler.endpoints import factory + +from .conftest import DATA_DIR + +from fastapi import FastAPI, HTTPException, Query + +from starlette.testclient import TestClient + + +def CustomPathParams(url: str = Query(..., description="Give me a url.",)) -> str: + """Custom path Dependency.""" + if not re.match("^c.+tif$", url): + raise HTTPException( + status_code=400, detail="Nope, this is not a valid URL - Please Try Again", + ) + return f"{DATA_DIR}/{url}" + + +def test_CustomPath(): + """Test Custom Render Params dependency.""" + app = FastAPI() + + cog = factory.TilerFactory(path_dependency=CustomPathParams) + app.include_router(cog.router) + client = TestClient(app) + + response = client.get("/preview.png?url=cog.tif&rescale=0,10000") + assert response.status_code == 200 + assert response.headers["content-type"] == "image/png" + + response = client.get("/preview.png?url=somethingelse.tif&rescale=0,10000") + assert response.status_code == 400 diff --git a/titiler/dependencies.py b/titiler/dependencies.py index 2111a26cf..01332af49 100644 --- a/titiler/dependencies.py +++ b/titiler/dependencies.py @@ -77,6 +77,11 @@ def ColorMapParams( return None +def DatasetPathParams(url: str = Query(..., description="Dataset URL")) -> str: + """Create dataset path from args""" + return url + + @dataclass class DefaultDependency: """Dependency Base Class""" @@ -84,13 +89,6 @@ class DefaultDependency: kwargs: Dict = field(init=False, default_factory=dict) -@dataclass -class PathParams: - """Create dataset path from args""" - - url: str = Query(..., description="Dataset URL") - - # Dependencies for simple BaseReader (e.g COGReader) @dataclass class BidxParams(DefaultDependency): diff --git a/titiler/endpoints/cog.py b/titiler/endpoints/cog.py index 99c1f2da8..ebd829997 100644 --- a/titiler/endpoints/cog.py +++ b/titiler/endpoints/cog.py @@ -3,7 +3,7 @@ from rio_cogeo.cogeo import cog_info as rio_cogeo_info from rio_tiler.io import COGReader -from ..dependencies import PathParams +from ..dependencies import DatasetPathParams from ..models.cogeo import Info from ..templates import templates from .factory import TilerFactory @@ -19,11 +19,11 @@ @cog.router.get("/validate", response_model=Info) def cog_validate( - src_path: PathParams = Depends(), + src_path: str = Depends(DatasetPathParams), strict: bool = Query(False, description="Treat warnings as errors"), ): """Validate a COG""" - return rio_cogeo_info(src_path.url, strict=strict) + return rio_cogeo_info(src_path, strict=strict) @cog.router.get("/viewer", response_class=HTMLResponse) diff --git a/titiler/endpoints/factory.py b/titiler/endpoints/factory.py index 881d4a64d..0bb204947 100644 --- a/titiler/endpoints/factory.py +++ b/titiler/endpoints/factory.py @@ -26,10 +26,10 @@ BidxParams, ColorMapParams, DatasetParams, + DatasetPathParams, DefaultDependency, ImageParams, MetadataParams, - PathParams, RenderParams, TileMatrixSetNames, TMSParams, @@ -76,7 +76,7 @@ class BaseTilerFactory(metaclass=abc.ABCMeta): router: APIRouter = field(default_factory=APIRouter) # Path Dependency - path_dependency: Type[PathParams] = PathParams + path_dependency: Callable[..., str] = DatasetPathParams # Rasterio Dataset Options (nodata, unscale, resampling) dataset_dependency: Type[DefaultDependency] = DatasetParams @@ -181,7 +181,7 @@ def bounds(self): def bounds(src_path=Depends(self.path_dependency)): """Return the bounds of the COG.""" with rasterio.Env(**self.gdal_config): - with self.reader(src_path.url, **self.reader_options) as src_dst: + with self.reader(src_path, **self.reader_options) as src_dst: return {"bounds": src_dst.bounds} ############################################################################ @@ -203,7 +203,7 @@ def info( ): """Return dataset's basic info.""" with rasterio.Env(**self.gdal_config): - with self.reader(src_path.url, **self.reader_options) as src_dst: + with self.reader(src_path, **self.reader_options) as src_dst: return src_dst.info(**kwargs) @self.router.get( @@ -224,11 +224,11 @@ def info_geojson( ): """Return dataset's basic info as a GeoJSON feature.""" with rasterio.Env(**self.gdal_config): - with self.reader(src_path.url, **self.reader_options) as src_dst: + with self.reader(src_path, **self.reader_options) as src_dst: info = src_dst.info(**kwargs).dict(exclude_none=True) bounds = info.pop("bounds", None) info.pop("center", None) - info["dataset"] = src_path.url + info["dataset"] = src_path geojson = utils.bbox_to_feature(bounds, properties=info) return geojson @@ -255,7 +255,7 @@ def metadata( ): """Return metadata.""" with rasterio.Env(**self.gdal_config): - with self.reader(src_path.url, **self.reader_options) as src_dst: + with self.reader(src_path, **self.reader_options) as src_dst: return src_dst.metadata( metadata_params.pmin, metadata_params.pmax, @@ -313,7 +313,7 @@ def tile( with utils.Timer() as t: with rasterio.Env(**self.gdal_config): with self.reader( - src_path.url, tms=tms, **self.reader_options + src_path, tms=tms, **self.reader_options ) as src_dst: data = src_dst.tile( x, @@ -414,9 +414,7 @@ def tilejson( tiles_url += f"?{qs}" with rasterio.Env(**self.gdal_config): - with self.reader( - src_path.url, tms=tms, **self.reader_options - ) as src_dst: + with self.reader(src_path, tms=tms, **self.reader_options) as src_dst: center = list(src_dst.center) if minzoom: center[-1] = minzoom @@ -425,7 +423,7 @@ def tilejson( "center": tuple(center), "minzoom": minzoom if minzoom is not None else src_dst.minzoom, "maxzoom": maxzoom if maxzoom is not None else src_dst.maxzoom, - "name": os.path.basename(src_path.url), + "name": os.path.basename(src_path), "tiles": [tiles_url], } @@ -483,9 +481,7 @@ def wmts( tiles_url += f"?{qs}" with rasterio.Env(**self.gdal_config): - with self.reader( - src_path.url, tms=tms, **self.reader_options - ) as src_dst: + with self.reader(src_path, tms=tms, **self.reader_options) as src_dst: bounds = src_dst.bounds minzoom = minzoom if minzoom is not None else src_dst.minzoom maxzoom = maxzoom if maxzoom is not None else src_dst.maxzoom @@ -544,7 +540,7 @@ def point( with utils.Timer() as t: with rasterio.Env(**self.gdal_config): - with self.reader(src_path.url, **self.reader_options) as src_dst: + with self.reader(src_path, **self.reader_options) as src_dst: values = src_dst.point( lon, lat, @@ -587,7 +583,7 @@ def preview( with utils.Timer() as t: with rasterio.Env(**self.gdal_config): - with self.reader(src_path.url, **self.reader_options) as src_dst: + with self.reader(src_path, **self.reader_options) as src_dst: data = src_dst.preview( **layer_params.kwargs, **img_params.kwargs, @@ -657,7 +653,7 @@ def part( with utils.Timer() as t: with rasterio.Env(**self.gdal_config): - with self.reader(src_path.url, **self.reader_options) as src_dst: + with self.reader(src_path, **self.reader_options) as src_dst: data = src_dst.part( [minx, miny, maxx, maxy], **layer_params.kwargs, @@ -735,7 +731,7 @@ def info( ): """Return dataset's basic info or the list of available assets.""" with rasterio.Env(**self.gdal_config): - with self.reader(src_path.url, **self.reader_options) as src_dst: + with self.reader(src_path, **self.reader_options) as src_dst: return src_dst.info(**asset_params.kwargs, **kwargs) @self.router.get( @@ -757,8 +753,8 @@ def info_geojson( ): """Return dataset's basic info as a GeoJSON feature.""" with rasterio.Env(**self.gdal_config): - with self.reader(src_path.url, **self.reader_options) as src_dst: - info = {"dataset": src_path.url} + with self.reader(src_path, **self.reader_options) as src_dst: + info = {"dataset": src_path} info["assets"] = { asset: meta.dict(exclude_none=True) for asset, meta in src_dst.info( @@ -780,7 +776,7 @@ def available_assets( ): """Return a list of supported assets.""" with rasterio.Env(**self.gdal_config): - with self.reader(src_path.url, **self.reader_options) as src_dst: + with self.reader(src_path, **self.reader_options) as src_dst: return src_dst.assets # Overwrite the `/metadata` endpoint because the MultiBaseReader output model is different (Dict[str, cogMetadata]) @@ -803,7 +799,7 @@ def metadata( ): """Return metadata.""" with rasterio.Env(**self.gdal_config): - with self.reader(src_path.url, **self.reader_options) as src_dst: + with self.reader(src_path, **self.reader_options) as src_dst: return src_dst.metadata( metadata_params.pmin, metadata_params.pmax, @@ -856,7 +852,7 @@ def info( ): """Return dataset's basic info or the list of available bands.""" with rasterio.Env(**self.gdal_config): - with self.reader(src_path.url, **self.reader_options) as src_dst: + with self.reader(src_path, **self.reader_options) as src_dst: return src_dst.info(**bands_params.kwargs, **kwargs) @self.router.get( @@ -878,8 +874,8 @@ def info_geojson( ): """Return dataset's basic info as a GeoJSON feature.""" with rasterio.Env(**self.gdal_config): - with self.reader(src_path.url, **self.reader_options) as src_dst: - info = {"dataset": src_path.url} + with self.reader(src_path, **self.reader_options) as src_dst: + info = {"dataset": src_path} info["bands"] = { band: meta.dict(exclude_none=True) for band, meta in src_dst.info( @@ -899,7 +895,7 @@ def available_bands( ): """Return a list of supported bands.""" with rasterio.Env(**self.gdal_config): - with self.reader(src_path.url, **self.reader_options) as src_dst: + with self.reader(src_path, **self.reader_options) as src_dst: return src_dst.bands def metadata(self): @@ -920,7 +916,7 @@ def metadata( ): """Return metadata.""" with rasterio.Env(**self.gdal_config): - with self.reader(src_path.url, **self.reader_options) as src_dst: + with self.reader(src_path, **self.reader_options) as src_dst: return src_dst.metadata( metadata_params.pmin, metadata_params.pmax, @@ -987,7 +983,7 @@ def read(self): ) def read(src_path=Depends(self.path_dependency),): """Read a MosaicJSON""" - with self.reader(src_path.url, **self.backend_options) as mosaic: + with self.reader(src_path, **self.backend_options) as mosaic: return mosaic.mosaic_def ############################################################################ @@ -1004,7 +1000,7 @@ def bounds(self): def bounds(src_path=Depends(self.path_dependency)): """Return the bounds of the COG.""" with rasterio.Env(**self.gdal_config): - with self.reader(src_path.url, **self.backend_options) as src_dst: + with self.reader(src_path, **self.backend_options) as src_dst: return {"bounds": src_dst.bounds} ############################################################################ @@ -1020,7 +1016,7 @@ def info(self): ) def info(src_path=Depends(self.path_dependency)): """Return basic info.""" - with self.reader(src_path.url, **self.backend_options) as src_dst: + with self.reader(src_path, **self.backend_options) as src_dst: return src_dst.info() @self.router.get( @@ -1041,11 +1037,11 @@ def info_geojson( ): """Return mosaic's basic info as a GeoJSON feature.""" with rasterio.Env(**self.gdal_config): - with self.reader(src_path.url, **self.backend_options) as src_dst: + with self.reader(src_path, **self.backend_options) as src_dst: info = src_dst.info(**kwargs).dict(exclude_none=True) bounds = info.pop("bounds", None) info.pop("center", None) - info["dataset"] = src_path.url + info["dataset"] = src_path geojson = utils.bbox_to_feature(bounds, properties=info) return geojson @@ -1102,7 +1098,7 @@ def tile( with utils.Timer() as t: with rasterio.Env(**self.gdal_config): with self.reader( - src_path.url, + src_path, reader=self.dataset_reader, reader_options=self.reader_options, **self.backend_options, @@ -1214,7 +1210,7 @@ def tilejson( qs = urlencode(list(q.items())) tiles_url += f"?{qs}" - with self.reader(src_path.url, **self.backend_options) as src_dst: + with self.reader(src_path, **self.backend_options) as src_dst: center = list(src_dst.center) if minzoom: center[-1] = minzoom @@ -1223,7 +1219,7 @@ def tilejson( "center": tuple(center), "minzoom": minzoom if minzoom is not None else src_dst.minzoom, "maxzoom": maxzoom if maxzoom is not None else src_dst.maxzoom, - "name": os.path.basename(src_path.url), + "name": os.path.basename(src_path), "tiles": [tiles_url], } @@ -1281,7 +1277,7 @@ def wmts( qs = urlencode(list(q.items())) tiles_url += f"?{qs}" - with self.reader(src_path.url, **self.backend_options) as src_dst: + with self.reader(src_path, **self.backend_options) as src_dst: bounds = src_dst.bounds minzoom = minzoom if minzoom is not None else src_dst.minzoom maxzoom = maxzoom if maxzoom is not None else src_dst.maxzoom @@ -1342,7 +1338,7 @@ def point( with utils.Timer() as t: with rasterio.Env(**self.gdal_config): with self.reader( - src_path.url, + src_path, reader=self.dataset_reader, reader_options=self.reader_options, **self.backend_options,