diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e9cae284e..e3d8f96a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ $ cd titiler $ pip install -e .[dev] ``` -**Python3.7 only** +**Python3.8 only** This repo is set to use `pre-commit` to run *isort*, *flake8*, *pydocstring*, *black* ("uncompromising Python code formatter") and mypy when committing new code. diff --git a/docs/concepts/APIRoute_and_environment_variables.md b/docs/advanced/APIRoute_and_environment_variables.md similarity index 100% rename from docs/concepts/APIRoute_and_environment_variables.md rename to docs/advanced/APIRoute_and_environment_variables.md diff --git a/docs/advanced/customization.md b/docs/advanced/customization.md new file mode 100644 index 000000000..c7da0c816 --- /dev/null +++ b/docs/advanced/customization.md @@ -0,0 +1,179 @@ + +`TiTiler` is designed to help user customize input/output for each endpoint. This section goes over some simple customization examples. + +### Custom DatasetPathParams for `path_dependency` + +One common customization could be to create your own `path_dependency` (used in all endpoints). + +Here an example which allow a mosaic to be passed by a `mosaic` name instead of a full S3 url. + +```python +import os +import re +from titiler.dependencies import DefaultDependency +from typing import Optional, Type +from rio_tiler.io import BaseReader +from fastapi import HTTPException, Query + +MOSAIC_BACKEND = os.getenv("TITILER_MOSAIC_BACKEND") +MOSAIC_HOST = os.getenv("TITILER_MOSAIC_HOST") + + +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}.", + ) + + return f"{MOSAIC_BACKEND}{MOSAIC_HOST}/{self.mosaic}.json.gz" +``` + +The endpoint url will now look like: `{endoint}/mosaic/tilejson.json?mosaic=vincent.mosaic` + + +### Custom TMS + +```python +from morecantile import tms, TileMatrixSet +from rasterio.crs import CRS + +from titiler.endpoint.factory import TilerFactory + +# 1. Create Custom TMS +EPSG6933 = TileMatrixSet.custom( + (-17357881.81713629, -7324184.56362408, 17357881.81713629, 7324184.56362408), + CRS.from_epsg(6933), + identifier="EPSG6933", + matrix_scale=[1, 1], +) + +# 2. Register TMS +tms = tms.register([EPSG6933]) + +# 3. Create ENUM with available TMS +TileMatrixSetNames = Enum( # type: ignore + "TileMatrixSetNames", [(a, a) for a in sorted(tms.list())] +) + +# 4. Create Custom TMS dependency +def TMSParams( + TileMatrixSetId: TileMatrixSetNames = Query( + TileMatrixSetNames.WebMercatorQuad, # type: ignore + description="TileMatrixSet Name (default: 'WebMercatorQuad')", + ) +) -> TileMatrixSet: + """TileMatrixSet Dependency.""" + return tms.get(TileMatrixSetId.name) + +# 5. Create Tiler +COGTilerWithCustomTMS = TilerFactory( + reader=COGReader, + tms_dependency=TMSParams, +) +``` + +### Add a MosaicJSON creation endpoint +```python + +from dataclasses import dataclass +from typing import List, Optional + +from titiler.endpoints.factory import MosaicTilerFactory +from titiler.errors import BadRequestError +from cogeo_mosaic.mosaic import MosaicJSON +from cogeo_mosaic.utils import get_footprints +import rasterio + +from pydantic import BaseModel + + +# Models from POST/PUT Body +class CreateMosaicJSON(BaseModel): + """Request body for MosaicJSON creation""" + + files: List[str] # Files to add to the mosaic + url: str # path where to save the mosaicJSON + minzoom: Optional[int] = None + maxzoom: Optional[int] = None + max_threads: int = 20 + overwrite: bool = False + + +class UpdateMosaicJSON(BaseModel): + """Request body for updating an existing MosaicJSON""" + + files: List[str] # Files to add to the mosaic + url: str # path where to save the mosaicJSON + max_threads: int = 20 + add_first: bool = True + + +@dataclass +class CustomMosaicFactory(MosaicTilerFactory): + + def register_routes(self): + """Update the class method to add create/update""" + super().register_routes() + # new methods/endpoint + self.create() + self.update() + + def create(self): + """Register / (POST) Create endpoint.""" + + @self.router.post( + "", response_model=MosaicJSON, response_model_exclude_none=True + ) + def create(body: CreateMosaicJSON): + """Create a MosaicJSON""" + # Write can write to either a local path, a S3 path... + # See https://developmentseed.org/cogeo-mosaic/advanced/backends/ for the list of supported backends + + # Create a MosaicJSON file from a list of URL + mosaic = MosaicJSON.from_urls( + body.files, + minzoom=body.minzoom, + maxzoom=body.maxzoom, + max_threads=body.max_threads, + ) + + # Write the MosaicJSON using a cogeo-mosaic backend + src_path = self.path_dependency(body.url) + with rasterio.Env(**self.gdal_config): + with self.reader( + src_path, mosaic_def=mosaic, reader=self.dataset_reader + ) as mosaic: + try: + mosaic.write(overwrite=body.overwrite) + except NotImplementedError: + raise BadRequestError( + f"{mosaic.__class__.__name__} does not support write operations" + ) + return mosaic.mosaic_def + + def update(self): + """Register / (PUST) Update endpoint.""" + + @self.router.put( + "", response_model=MosaicJSON, response_model_exclude_none=True + ) + def update_mosaicjson(body: UpdateMosaicJSON): + """Update an existing MosaicJSON""" + src_path = self.path_dependency(body.url) + with rasterio.Env(**self.gdal_config): + 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) + except NotImplementedError: + raise BadRequestError( + f"{mosaic.__class__.__name__} does not support update operations" + ) + return mosaic.mosaic_def + +``` diff --git a/docs/concepts/dependencies.md b/docs/advanced/dependencies.md similarity index 95% rename from docs/concepts/dependencies.md rename to docs/advanced/dependencies.md index 75796ebd2..59b4fa20f 100644 --- a/docs/concepts/dependencies.md +++ b/docs/advanced/dependencies.md @@ -177,11 +177,19 @@ The `factories` allow users to set multiple default dependencies. Here is the li ```python def ColorMapParams( - color_map: ColorMapNames = Query(None, description="Colormap name",) + colormap_name: ColorMapName = Query(None, description="Colormap name"), + colormap: str = Query(None, description="JSON encoded custom Colormap"), ) -> Optional[Dict]: """Colormap Dependency.""" - if color_map: - return cmap.get(color_map.value) + if colormap_name: + return cmap.get(colormap_name.value) + + if colormap: + return json.loads( + colormap, + object_hook=lambda x: {int(k): parse_color(v) for k, v in x.items()}, + ) + return None ``` diff --git a/docs/concepts/tiler_factories.md b/docs/advanced/tiler_factories.md similarity index 100% rename from docs/concepts/tiler_factories.md rename to docs/advanced/tiler_factories.md diff --git a/docs/concepts/customization.md b/docs/concepts/customization.md deleted file mode 100644 index a86853b92..000000000 --- a/docs/concepts/customization.md +++ /dev/null @@ -1,363 +0,0 @@ - -### Example of STAC Tiler - -While a STAC tiler is included in the default TiTiler application, it provides -an illustrative example of why one might need a custom tiler. The default -factories create endpoints that expect basic input like `indexes=[1, 2, 3]` and -`resampling_method='nearest'` but STAC needs more info. The STAC reader provided -by `rio-tiler` and `rio-tiler-crs` needs an `assets=` option to specify which -STAC asset(s) you want to read. - -We can add additional dependencies to endpoint by using the `additional_dependency` options when creating the factory. - -```python -from dataclasses import dataclass -from titiler.endpoints.factory import TilerFactory -from rio_tiler_crs import STACReader -from titiler.dependencies import DefaultDependency - -@dataclass -class AssetsParams(DefaultDependency): - """Asset and Band indexes parameters.""" - - assets: Optional[str] = Query( - None, - title="Asset indexes", - description="comma (',') delimited asset names (might not be an available options of some readers)", - ) - - def __post_init__(self): - """Post Init.""" - if self.assets is not None: - self.kwargs["assets"] = self.assets.split(",") - - -stac = TilerFactory( - reader=STACReader, - additional_dependency=AssetsParams, - router_prefix="stac", -) -``` - -With `additional_dependency` set to `AssetsParams`, each endpoint will now have `assets` as one of input function. - -While this is good, it's not enough. STACTiler `metadata()` and `info()` methods return a slightly different output that the usual COGReader (because of multiple assets). We then need to customize a bit more the tiler: - -```python -from titiler.dependencies import DefaultDependency -from titiler.endpoint.factory import TilerFactory -from titiler.models.cog import cogInfo, cogMetadata - - -@dataclass -class AssetsBidxParams(DefaultDependency): - """Asset and Band indexes parameters.""" - - assets: Optional[str] = Query( - None, - title="Asset indexes", - description="comma (',') delimited asset names (might not be an available options of some readers)", - ) - bidx: Optional[str] = Query( - None, title="Band indexes", description="comma (',') delimited band indexes", - ) - - def __post_init__(self): - """Post Init.""" - if self.assets is not None: - self.kwargs["assets"] = self.assets.split(",") - if self.bidx is not None: - self.kwargs["indexes"] = tuple( - int(s) for s in re.findall(r"\d+", self.bidx) - ) - - -@dataclass -class AssetsBidxExprParams(DefaultDependency): - """Assets, Band Indexes and Expression parameters.""" - - assets: Optional[str] = Query( - None, - title="Asset indexes", - description="comma (',') delimited asset names (might not be an available options of some readers)", - ) - expression: Optional[str] = Query( - None, - title="Band Math expression", - description="rio-tiler's band math expression (e.g B1/B2)", - ) - bidx: Optional[str] = Query( - None, title="Band indexes", description="comma (',') delimited band indexes", - ) - - def __post_init__(self): - """Post Init.""" - if self.assets is not None: - self.kwargs["assets"] = self.assets.split(",") - if self.expression is not None: - self.kwargs["expression"] = self.expression - if self.bidx is not None: - self.kwargs["indexes"] = tuple( - int(s) for s in re.findall(r"\d+", self.bidx) - ) - - -# We create a Sub-Class from the TilerFactory and update 2 methods. -@dataclass -class STACTiler(TilerFactory): - """Custom Tiler Class for STAC.""" - - reader: Type[STACReader] = STACReader # We set the Reader to STACReader by default - - layer_dependency: Type[DefaultDependency] = AssetsBidxExprParams - - # Overwrite info method to return the list of assets when no assets is passed. - # 2 changes from the _info in the original factory: - # - response_model: - # response_model=cogInfo -> response_model=Union[List[str], Dict[str, cogInfo]] - # The output of STACTiler.info is a dict in form of {"asset1": {`cogIngo`}} - # - Return list of assets if no `assets` option passed - # This can be usefull in case we don't know the assets present in the STAC item. - def info(self): - """Register /info endpoint.""" - - @self.router.get( - "/info", - response_model=Union[List[str], Dict[str, Info]], - response_model_exclude={"minzoom", "maxzoom", "center"}, - response_model_exclude_none=True, - responses={200: {"description": "Return dataset's basic info."}}, - ) - def info( - src_path=Depends(self.path_dependency), - asset_params=Depends(AssetsBidxParams), - kwargs: Dict = Depends(self.additional_dependency), - ): - """Return basic info.""" - with self.reader(src_path.url, **self.reader_options) as src_dst: - # `Assets` is a required options for `info`, - # if not set we return the list of assets - if not asset_params.assets: - return src_dst.assets - info = src_dst.info(**asset_params.kwargs, **kwargs) - return info - - - # Overwrite _metadata method because the STACTiler output model is different - # response_model=cogMetadata -> response_model=Dict[str, cogMetadata] - # Same as for info(), we update the output model to match the output result from STACTiler.metadata - def metadata(self): - """Register /metadata endpoint.""" - - @self.router.get( - "/metadata", - response_model=Dict[str, Metadata], - response_model_exclude={"minzoom", "maxzoom", "center"}, - response_model_exclude_none=True, - responses={200: {"description": "Return dataset's metadata."}}, - ) - def metadata( - src_path=Depends(self.path_dependency), - asset_params=Depends(AssetsBidxParams), - metadata_params=Depends(self.metadata_dependency), - kwargs: Dict = Depends(self.additional_dependency), - ): - """Return metadata.""" - with self.reader(src_path.url, **self.reader_options) as src_dst: - info = src_dst.metadata( - metadata_params.pmin, - metadata_params.pmax, - **asset_params.kwargs, - **metadata_params.kwargs, - **kwargs, - ) - return info - - -stac = STACTiler(router_prefix="stac") -``` - -### Custom DatasetPathParams for `path_dependency` - -One common customization could be to create your own `path_dependency` (used in all endpoints). - -Here an example which allow a mosaic to be passed by a layer name instead of a full S3 url. - -```python -import os -import re -from titiler.dependencies import DefaultDependency -from typing import Optional, Type -from rio_tiler.io import BaseReader -from fastapi import HTTPException, Query - -MOSAIC_BACKEND = os.getenv("TITILER_MOSAIC_BACKEND") -MOSAIC_HOST = os.getenv("TITILER_MOSAIC_HOST") - - -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}.", - ) - - return f"{MOSAIC_BACKEND}{MOSAIC_HOST}/{self.mosaic}.json.gz" -``` - -### Custom TMS - -```python - -from morecantile import tms, TileMatrixSet -from rasterio.crs import CRS - -from titiler.endpoint.factory import TilerFactory - -# 1. Create Custom TMS -EPSG6933 = TileMatrixSet.custom( - (-17357881.81713629, -7324184.56362408, 17357881.81713629, 7324184.56362408), - CRS.from_epsg(6933), - identifier="EPSG6933", - matrix_scale=[1, 1], -) - -# 2. Register TMS -tms = tms.register([EPSG6933]) - -# 3. Create ENUM with available TMS -TileMatrixSetNames = Enum( # type: ignore - "TileMatrixSetNames", [(a, a) for a in sorted(tms.list())] -) - -# 4. Create Custom TMS dependency -def TMSParams( - TileMatrixSetId: TileMatrixSetNames = Query( - TileMatrixSetNames.WebMercatorQuad, # type: ignore - description="TileMatrixSet Name (default: 'WebMercatorQuad')", - ) -) -> TileMatrixSet: - """TileMatrixSet Dependency.""" - return tms.get(TileMatrixSetId.name) - -# 5. Create Tiler -COGTilerWithCustomTMS = TilerFactory( - reader=COGReader, - tms_dependency=TMSParams, -) -``` - -### Add a MosaicJSON creation endpoint -```python - -from dataclasses import dataclass -from typing import List, Optional - -from titiler.endpoints.factory import MosaicTilerFactory -from titiler.errors import BadRequestError -from cogeo_mosaic.mosaic import MosaicJSON -from cogeo_mosaic.utils import get_footprints -import rasterio - -from pydantic import BaseModel - - -# Models from POST/PUT Body -class CreateMosaicJSON(BaseModel): - """Request body for MosaicJSON creation""" - - files: List[str] # Files to add to the mosaic - url: str # path where to save the mosaicJSON - minzoom: Optional[int] = None - maxzoom: Optional[int] = None - max_threads: int = 20 - overwrite: bool = False - - -class UpdateMosaicJSON(BaseModel): - """Request body for updating an existing MosaicJSON""" - - files: List[str] # Files to add to the mosaic - url: str # path where to save the mosaicJSON - max_threads: int = 20 - add_first: bool = True - - -@dataclass -class CustomMosaicFactory(MosaicTilerFactory): - - def register_routes(self): - """Update the class method to add create/update""" - self.read() - self.bounds() - self.info() - self.tile() - self.tilejson() - self.wmts() - self.point() - self.validate() - # new methods/endpoint - self.create() - self.update() - - def create(self): - """Register / (POST) Create endpoint.""" - - @self.router.post( - "", response_model=MosaicJSON, response_model_exclude_none=True - ) - def create(body: CreateMosaicJSON): - """Create a MosaicJSON""" - # Write can write to either a local path, a S3 path... - # See https://developmentseed.org/cogeo-mosaic/advanced/backends/ for the list of supported backends - - # Create a MosaicJSON file from a list of URL - mosaic = MosaicJSON.from_urls( - body.files, - minzoom=body.minzoom, - maxzoom=body.maxzoom, - max_threads=body.max_threads, - ) - - # Write the MosaicJSON using a cogeo-mosaic backend - src_path = self.path_dependency(body.url) - with rasterio.Env(**self.gdal_config): - with self.reader( - src_path, mosaic_def=mosaic, reader=self.dataset_reader - ) as mosaic: - try: - mosaic.write(overwrite=body.overwrite) - except NotImplementedError: - raise BadRequestError( - f"{mosaic.__class__.__name__} does not support write operations" - ) - return mosaic.mosaic_def - - ############################################################################ - # /update - ############################################################################ - def update(self): - """Register / (PUST) Update endpoint.""" - - @self.router.put( - "", response_model=MosaicJSON, response_model_exclude_none=True - ) - def update_mosaicjson(body: UpdateMosaicJSON): - """Update an existing MosaicJSON""" - src_path = self.path_dependency(body.url) - with rasterio.Env(**self.gdal_config): - 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) - except NotImplementedError: - raise BadRequestError( - f"{mosaic.__class__.__name__} does not support update operations" - ) - return mosaic.mosaic_def - -``` diff --git a/docs/concepts/dynamic_tiling.md b/docs/dynamic_tiling.md similarity index 100% rename from docs/concepts/dynamic_tiling.md rename to docs/dynamic_tiling.md diff --git a/docs/endpoints/cog.md b/docs/endpoints/cog.md index cf072daa7..86613aa10 100644 --- a/docs/endpoints/cog.md +++ b/docs/endpoints/cog.md @@ -61,7 +61,7 @@ Example: - `https://myendpoint/cog/tiles/1/2/3?url=https://somewhere.com/mycog.tif` - `https://myendpoint/cog/tiles/1/2/3.jpg?url=https://somewhere.com/mycog.tif` - `https://myendpoint/cog/tiles/WorldCRS84Quad/1/2/3@2x.png?url=https://somewhere.com/mycog.tif` -- `https://myendpoint/cog/tiles/WorldCRS84Quad/1/2/3?url=https://somewhere.com/mycog.tif&bidx=1&rescale=0,1000&color_map=cfastie` +- `https://myendpoint/cog/tiles/WorldCRS84Quad/1/2/3?url=https://somewhere.com/mycog.tif&bidx=1&rescale=0,1000&colormap_name=cfastie` ### Preview @@ -90,7 +90,7 @@ Example: - `https://myendpoint/cog/preview?url=https://somewhere.com/mycog.tif` - `https://myendpoint/cog/preview.jpg?url=https://somewhere.com/mycog.tif` -- `https://myendpoint/cog/preview?url=https://somewhere.com/mycog.tif&bidx=1&rescale=0,1000&color_map=cfastie` +- `https://myendpoint/cog/preview?url=https://somewhere.com/mycog.tif&bidx=1&rescale=0,1000&colormap_name=cfastie` ### Crop / Part @@ -120,7 +120,7 @@ Note: if `height` and `width` are provided `max_size` will be ignored. Example: - `https://myendpoint/cog/crop/0,0,10,10.png?url=https://somewhere.com/mycog.tif` -- `https://myendpoint/cog/crop/0,0,10,10.png?url=https://somewhere.com/mycog.tif&bidx=1&rescale=0,1000&color_map=cfastie` +- `https://myendpoint/cog/crop/0,0,10,10.png?url=https://somewhere.com/mycog.tif&bidx=1&rescale=0,1000&colormap_name=cfastie` ### Point diff --git a/docs/endpoints/stac.md b/docs/endpoints/stac.md index 3679e8610..8977b8e99 100644 --- a/docs/endpoints/stac.md +++ b/docs/endpoints/stac.md @@ -74,7 +74,7 @@ Example: - `https://myendpoint/stac/tiles/1/2/3?url=https://somewhere.com/item.json&assets=B01` - `https://myendpoint/stac/tiles/1/2/3.jpg?url=https://somewhere.com/item.json&assets=B01` - `https://myendpoint/stac/tiles/WorldCRS84Quad/1/2/3@2x.png?url=https://somewhere.com/item.json&assets=B01` -- `https://myendpoint/stac/tiles/WorldCRS84Quad/1/2/3?url=https://somewhere.com/item.json&expression=B01/B02&rescale=0,1000&color_map=cfastie` +- `https://myendpoint/stac/tiles/WorldCRS84Quad/1/2/3?url=https://somewhere.com/item.json&expression=B01/B02&rescale=0,1000&colormap_name=cfastie` ### Preview @@ -106,7 +106,7 @@ Example: - `https://myendpoint/stac/preview?url=https://somewhere.com/item.json&assets=B01` - `https://myendpoint/stac/preview.jpg?url=https://somewhere.com/item.json&assets=B01` -- `https://myendpoint/stac/preview?url=https://somewhere.com/item.json&assets=B01&rescale=0,1000&color_map=cfastie` +- `https://myendpoint/stac/preview?url=https://somewhere.com/item.json&assets=B01&rescale=0,1000&colormap_name=cfastie` ### Crop / Part @@ -139,7 +139,7 @@ Note: if `height` and `width` are provided `max_size` will be ignored. Example: - `https://myendpoint/stac/crop/0,0,10,10.png?url=https://somewhere.com/item.json&assets=B01` -- `https://myendpoint/stac/crop/0,0,10,10.png?url=https://somewhere.com/item.json&assets=B01&rescale=0,1000&color_map=cfastie` +- `https://myendpoint/stac/crop/0,0,10,10.png?url=https://somewhere.com/item.json&assets=B01&rescale=0,1000&colormap_name=cfastie` ### Point diff --git a/docs/examples/code/mini_cog_tiler.md b/docs/examples/code/mini_cog_tiler.md new file mode 100644 index 000000000..da6373de9 --- /dev/null +++ b/docs/examples/code/mini_cog_tiler.md @@ -0,0 +1,28 @@ + +**Goal**: Create a simple Raster tiler + +**requirements**: titiler + + +```python +"""Minimal COG tiler.""" + +from titiler.endpoints.factory import TilerFactory +from titiler.errors import DEFAULT_STATUS_CODES, add_exception_handlers + +from fastapi import FastAPI + + +app = FastAPI(title="My simple app") + +cog = TilerFactory() +app.include_router(cog.router, tags=["Cloud Optimized GeoTIFF"]) + +add_exception_handlers(app, DEFAULT_STATUS_CODES) + + +@app.get("/healthz", description="Health Check", tags=["Health Check"]) +def ping(): + """Health check.""" + return {"ping": "pong!"} +``` diff --git a/docs/examples/code/tiler_with_auth.md b/docs/examples/code/tiler_with_auth.md new file mode 100644 index 000000000..ab96069b9 --- /dev/null +++ b/docs/examples/code/tiler_with_auth.md @@ -0,0 +1,220 @@ +**Goal**: Add simple token auth + +**requirements**: titiler, python-jose[cryptography] + + +Learn more about security over [FastAPI documentation](https://fastapi.tiangolo.com/tutorial/security/) + +1 - Security settings (secret key) + +```python +"""Security Settings. + +app/settings.py + +""" + +from pydantic import BaseSettings + + +class AuthSettings(BaseSettings): + """Application settings""" + + # Create secret key using `openssl rand -hex 32` + # example: "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7" + secret: str + expires: int = 3600 + algorithm: str = "HS256" + + class Config: + """model config""" + + env_prefix = "SECURITY_" + + +auth_config = AuthSettings() +``` + +2 - Create a Token `Model` + +```python +"""Models. + +app/models.py + +""" + +from datetime import datetime, timedelta +from typing import List, Optional + +from jose import jwt +from pydantic import BaseModel, Field, validator + +from .settings import auth_config + +# We add scopes - because we are fancy +availables_scopes = ["tiles:read"] + + +class AccessToken(BaseModel): + """API Token info.""" + + sub: str = Field(..., alias="username", regex="^[a-zA-Z0-9-_]{1,32}$") + scope: List = ["tiles:read"] + iat: Optional[datetime] = None + exp: Optional[datetime] = None + groups: Optional[List[str]] + + @validator("iat", pre=True, always=True) + def set_creation_time(cls, v) -> datetime: + """Set token creation time (iat).""" + return datetime.utcnow() + + @validator("exp", always=True) + def set_expiration_time(cls, v, values) -> datetime: + """Set token expiration time (iat).""" + return values["iat"] + timedelta(seconds=auth_config.expires) + + @validator("scope", each_item=True) + def valid_scopes(cls, v, values): + """Validate Scopes.""" + v = v.lower() + if v not in availables_scopes: + raise ValueError(f"Invalid scope: {v}") + return v.lower() + + class Config: + """Access Token Model config.""" + + extra = "forbid" + + @property + def username(self) -> str: + """Return Username.""" + return self.sub + + def __str__(self): + """Create jwt token string.""" + return jwt.encode( + self.dict(exclude_none=True), + auth_config.secret, + algorithm=auth_config.algorithm, + ) + + @classmethod + def from_string(cls, token: str): + """Parse jwt token string.""" + res = jwt.decode(token, auth_config.secret, algorithms=[auth_config.algorithm]) + user = res.pop("sub") + res["username"] = user + return cls(**res) +``` + +3 - Create a custom `path dependency` + +The `DatasetPathParams` will add 2 querystring parameter to our application: +- `url`: the dataset url (like in the regular titiler app) +- `access_token`: our `token` parameter + +```python +"""Dependencies. + +app/dependencies.py + +""" + +from jose import JWTError + +from fastapi import HTTPException, Query, Security +from fastapi.security.api_key import APIKeyQuery + +from .models import AccessToken + +api_key_query = APIKeyQuery(name="access_token", auto_error=False) + + +# Custom Dataset Path dependency +def DatasetPathParams( + url: str = Query(..., description="Dataset URL"), + api_key_query: str = Security(api_key_query) +) -> str: + """Create dataset path from args""" + + if not api_key_query: + raise HTTPException(status_code=403, detail="Missing `access_token`") + + try: + AccessToken.from_string(api_key_query) + except JWTError: + raise HTTPException(status_code=403, detail="Invalid `access_token`") + + return url +``` + + +3b - Create a Token creation/read endpoint (Optional) + +```python +"""Tokens App. + +app/tokens.py + +""" + +from typing import Any, Dict + +from .models import AccessToken + +from fastapi import APIRouter, Query + +router = APIRouter() + + +@router.post(r"/create", responses={200: {"description": "Create a token"}}) +def create_token(body: AccessToken): + """create token.""" + return {"token": str(body)} + + +@router.get(r"/create", responses={200: {"description": "Create a token"}}) +def get_token( + username: str = Query(..., description="Username"), + scope: str = Query(None, description="Coma (,) delimited token scopes"), +): + """create token.""" + params: Dict[str, Any] = {"username": username} + if scope: + params["scope"] = scope.split(",") + token = AccessToken(**params) + return {"token": str(token)} +``` + +4 - Create the Tiler app with our custom `DatasetPathParams` + +```python +"""app + +app/main.py + +""" + +from titiler.endpoints.factory import TilerFactory +from titiler.errors import DEFAULT_STATUS_CODES, add_exception_handlers + +from fastapi import FastAPI + +from .dependencies import DatasetPathParams + +app = FastAPI(title="My simple app with auth") + +# here we create a custom Tiler with out custom DatasetPathParams function +cog = TilerFactory(path_dependency=DatasetPathParams) +app.include_router(cog.router, tags=["Cloud Optimized GeoTIFF"]) + +# optional +from . import tokens +app.include_router(tokens.router) + +add_exception_handlers(app, DEFAULT_STATUS_CODES) +``` + diff --git a/docs/examples/code/tiler_with_cache.md b/docs/examples/code/tiler_with_cache.md new file mode 100644 index 000000000..a7bdd51b2 --- /dev/null +++ b/docs/examples/code/tiler_with_cache.md @@ -0,0 +1,305 @@ +**Goal**: Add a cache layer on top of the tiler + +**requirements**: titiler, aiocache[redis] + +1 - Cache settings + +```python +"""settings. + +app/settings.py + +""" + +from pydantic import BaseSettings +from typing import Optional + + +class CacheSettings(BaseSettings): + """Cache settings""" + + endpoint: Optional[str] = None + ttl: int = 3600 + + class Config: + """model config""" + + env_file = ".env" + env_prefix = "CACHE_" + + +cache_setting = CacheSettings() +``` + +2 - Cache plugin + +Because `aiocache.cached` doesn't support non-async method we have to create a custom `cached` class + +```python +"""Cache Plugin. + +app/cache.py + +""" + +import asyncio +import urllib +from typing import Any, Dict + +import aiocache +from starlette.responses import Response + +from fastapi.dependencies.utils import is_coroutine_callable + +from .settings import cache_setting + + +class cached(aiocache.cached): + """Custom Cached Decorator.""" + + async def get_from_cache(self, key): + try: + value = await self.cache.get(key) + if isinstance(value, Response): + value.headers["X-Cache"] = "HIT" + return value + except Exception: + aiocache.logger.exception( + "Couldn't retrieve %s, unexpected error", key + ) + + async def decorator( + self, + f, + *args, + cache_read=True, + cache_write=True, + aiocache_wait_for_write=True, + **kwargs, + ): + key = self.get_cache_key(f, args, kwargs) + + if cache_read: + value = await self.get_from_cache(key) + if value is not None: + return value + + # CUSTOM, we add support for non-async method + # NOTE: Maybe this is a bad idea!!! + if is_coroutine_callable(f): + result = await f(*args, **kwargs) + else: + result = f(*args, **kwargs) + + if cache_write: + if aiocache_wait_for_write: + await self.set_in_cache(key, result) + else: + asyncio.ensure_future(self.set_in_cache(key, result)) + + return result + + +def setup_cache(): + """Setup aiocache.""" + config: Dict[str, Any] = { + 'cache': "aiocache.SimpleMemoryCache", + 'serializer': { + 'class': "aiocache.serializers.PickleSerializer" + } + } + if cache_settings.ttl is not None: + config["ttl"] = cache_settings.ttl + + if cache_settings.endpoint: + url = urllib.parse.urlparse(cache_settings.endpoint) + ulr_config = dict(urllib.parse.parse_qsl(url.query)) + config.update(ulr_config) + + cache_class = aiocache.Cache.get_scheme_class(url.scheme) + config.update(cache_class.parse_uri_path(url.path)) + config["endpoint"] = url.hostname + config["port"] = str(url.port) + + if url.password: + config["password"] = url.password + + if cache_class == aiocache.Cache.REDIS: + config["cache"] = "aiocache.RedisCache" + elif cache_class == aiocache.Cache.MEMCACHED: + config["cache"] = "aiocache.MemcachedCache" + + aiocache.caches.set_config({"default": config}) +``` + +3 - Write a custom minimal Tiler with Cache + +```python +"""routes. + +app/routes.py +""" +from dataclasses import dataclass +from typing import Callable, Dict, Type +from urllib.parse import urlencode + +from fastapi import Depends, Path +from starlette.requests import Request +from starlette.responses import Response + +from morecantile import TileMatrixSet +from rio_tiler.io import BaseReader, COGReader +from titiler.endpoints.factory import BaseTilerFactory, img_endpoint_params +from titiler.dependencies import ImageParams, MetadataParams, TMSParams +from titiler.models.mapbox import TileJSON +from titiler.resources.enums import ImageType + +from .cache import cached + + +@dataclass +class TilerFactory(BaseTilerFactory): + + # Default reader is set to COGReader + reader: Type[BaseReader] = COGReader + + # Endpoint Dependencies + metadata_dependency: Type[DefaultDependency] = MetadataParams + img_dependency: Type[DefaultDependency] = ImageParams + + # TileMatrixSet dependency + tms_dependency: Callable[..., TileMatrixSet] = TMSParams + + def register_routes(self): + """This Method register routes to the router.""" + + self.tile() + self.tilejson() + + def tile(self): + """Register /tiles endpoint.""" + + @self.router.get(r"/tiles/{z}/{x}/{y}", **img_endpoint_params) + @self.router.get(r"/tiles/{TileMatrixSetId}/{z}/{x}/{y}", **img_endpoint_params) + @cached() + def tile( + z: int = Path(..., ge=0, le=30, description="Tiles's zoom level"), + x: int = Path(..., description="Tiles's column"), + y: int = Path(..., description="Tiles's row"), + tms: TileMatrixSet = Depends(self.tms_dependency), + src_path=Depends(self.path_dependency), + layer_params=Depends(self.layer_dependency), + dataset_params=Depends(self.dataset_dependency), + render_params=Depends(self.render_dependency), + colormap=Depends(self.colormap_dependency), + kwargs: Dict = Depends(self.additional_dependency), + ): + """Create map tile from a dataset.""" + with self.reader(src_path, tms=tms, **self.reader_options) as src_dst: + data = src_dst.tile( + x, + y, + z, + **layer_params.kwargs, + **dataset_params.kwargs, + **kwargs, + ) + dst_colormap = getattr(src_dst, "colormap", None) + + format = ImageType.jpeg if data.mask.all() else ImageType.png + + image = data.post_process( + in_range=render_params.rescale_range, + color_formula=render_params.color_formula, + ) + + content = image.render( + add_mask=render_params.return_mask, + img_format=format.driver, + colormap=colormap or dst_colormap, + **format.profile, + **render_params.kwargs, + ) + + return Response(content, media_type=format.mediatype) + + def tilejson(self): + """Register /tilejson.json endpoint.""" + + @self.router.get( + "/tilejson.json", + response_model=TileJSON, + responses={200: {"description": "Return a tilejson"}}, + response_model_exclude_none=True, + ) + @self.router.get( + "/{TileMatrixSetId}/tilejson.json", + response_model=TileJSON, + responses={200: {"description": "Return a tilejson"}}, + response_model_exclude_none=True, + ) + @cached() + def tilejson( + request: Request, + tms: TileMatrixSet = Depends(self.tms_dependency), + src_path=Depends(self.path_dependency), + layer_params=Depends(self.layer_dependency), + dataset_params=Depends(self.dataset_dependency), + render_params=Depends(self.render_dependency), + colormap=Depends(self.colormap_dependency), + kwargs: Dict = Depends(self.additional_dependency), + ): + """Return TileJSON document for a dataset.""" + route_params = { + "z": "{z}", + "x": "{x}", + "y": "{y}", + "TileMatrixSetId": tms.identifier, + } + tiles_url = self.url_for(request, "tile", **route_params) + + q = dict(request.query_params) + q.pop("TileMatrixSetId", None) + qs = urlencode(list(q.items())) + tiles_url += f"?{qs}" + + with self.reader(src_path, tms=tms, **self.reader_options) as src_dst: + return { + "bounds": src_dst.bounds, + "center": src_dst.center, + "minzoom": src_dst.minzoom, + "maxzoom": src_dst.maxzoom, + "name": "cogeotif", + "tiles": [tiles_url], + } + + +cog = TilerFactory() +``` + +4 - Create the Tiler app with our custom `DatasetPathParams` + +```python +"""app + +app/main.py + +""" + +from titiler.endpoints.factory import TilerFactory +from titiler.errors import DEFAULT_STATUS_CODES, add_exception_handlers + +from fastapi import FastAPI + +from .cache import setup_cache +from .routes import cog + +app = FastAPI(title="My simple app with cache") + +# Setup Cache on Startup +app.add_event_handler("startup", setup_cache) + +add_exception_handlers(app, DEFAULT_STATUS_CODES) + +app.include_router(cog.router, tags=["Cloud Optimized GeoTIFF"]) +``` diff --git a/docs/examples/code/tiler_with_custom_tms.md b/docs/examples/code/tiler_with_custom_tms.md new file mode 100644 index 000000000..cacbb8a88 --- /dev/null +++ b/docs/examples/code/tiler_with_custom_tms.md @@ -0,0 +1,85 @@ + +**Goal**: add custom TMS to a tiler + +**requirements**: titiler + + +1 - Create a custom `TMSParams` dependency + +```python +"""dependencies. + +app/dependencies.py + +""" + +from morecantile import tms, TileMatrixSet +from rasterio.crs import CRS + +# 1. Create Custom TMS +EPSG6933 = TileMatrixSet.custom( + (-17357881.81713629, -7324184.56362408, 17357881.81713629, 7324184.56362408), + CRS.from_epsg(6933), + identifier="EPSG6933", + matrix_scale=[1, 1], +) + +# 2. Register TMS +tms = tms.register([EPSG6933]) + +# 3. Create ENUM with available TMS +TileMatrixSetNames = Enum( # type: ignore + "TileMatrixSetNames", [(a, a) for a in sorted(tms.list())] +) + +# 4. Create Custom TMS dependency +def TMSParams( + TileMatrixSetId: TileMatrixSetNames = Query( + TileMatrixSetNames.WebMercatorQuad, # type: ignore + description="TileMatrixSet Name (default: 'WebMercatorQuad')", + ) +) -> TileMatrixSet: + """TileMatrixSet Dependency.""" + return tms.get(TileMatrixSetId.name) +``` + +2 - Create endpoints + +```python +"""routes. + +app/routes.py + +""" + +from titiler.endpoints.factory import TilerFactory, TMSFactory + +from .dependencies import TileMatrixSetName, TMSParams + + +tms = TMSFactory(supported_tms=TileMatrixSetName, tms_dependency=TMSParams) + +cog = TilerFactory(tms_dependency=TMSParams) +``` + +3 - Create app and register our custom endpoints + +```python +"""app. + +app/main.py + +""" + +from titiler.errors import DEFAULT_STATUS_CODES, add_exception_handlers + +from fastapi import FastAPI + +from .routes import cog, tms + +app = FastAPI(title="My simple app with custom TMS") + +app.include_router(cog.router, tags=["Cloud Optimized GeoTIFF"]) +app.include_router(tms.router, tags=["TileMatrixSets"]) +add_exception_handlers(app, DEFAULT_STATUS_CODES) +``` diff --git a/docs/examples/Create_CustomSentinel2Tiler.ipynb b/docs/examples/notebooks/Create_CustomSentinel2Tiler.ipynb similarity index 100% rename from docs/examples/Create_CustomSentinel2Tiler.ipynb rename to docs/examples/notebooks/Create_CustomSentinel2Tiler.ipynb diff --git a/docs/examples/Working_with_CloudOptimizedGeoTIFF.ipynb b/docs/examples/notebooks/Working_with_CloudOptimizedGeoTIFF.ipynb similarity index 100% rename from docs/examples/Working_with_CloudOptimizedGeoTIFF.ipynb rename to docs/examples/notebooks/Working_with_CloudOptimizedGeoTIFF.ipynb diff --git a/docs/examples/Working_with_CloudOptimizedGeoTIFF_simple.ipynb b/docs/examples/notebooks/Working_with_CloudOptimizedGeoTIFF_simple.ipynb similarity index 100% rename from docs/examples/Working_with_CloudOptimizedGeoTIFF_simple.ipynb rename to docs/examples/notebooks/Working_with_CloudOptimizedGeoTIFF_simple.ipynb diff --git a/docs/examples/Working_with_MosaicJSON.ipynb b/docs/examples/notebooks/Working_with_MosaicJSON.ipynb similarity index 100% rename from docs/examples/Working_with_MosaicJSON.ipynb rename to docs/examples/notebooks/Working_with_MosaicJSON.ipynb diff --git a/docs/examples/Working_with_NumpyTile.ipynb b/docs/examples/notebooks/Working_with_NumpyTile.ipynb similarity index 100% rename from docs/examples/Working_with_NumpyTile.ipynb rename to docs/examples/notebooks/Working_with_NumpyTile.ipynb diff --git a/docs/examples/Working_with_STAC.ipynb b/docs/examples/notebooks/Working_with_STAC.ipynb similarity index 100% rename from docs/examples/Working_with_STAC.ipynb rename to docs/examples/notebooks/Working_with_STAC.ipynb diff --git a/docs/examples/Working_with_STAC_simple.ipynb b/docs/examples/notebooks/Working_with_STAC_simple.ipynb similarity index 100% rename from docs/examples/Working_with_STAC_simple.ipynb rename to docs/examples/notebooks/Working_with_STAC_simple.ipynb diff --git a/docs/examples/Working_with_nonWebMercatorTMS.ipynb b/docs/examples/notebooks/Working_with_nonWebMercatorTMS.ipynb similarity index 100% rename from docs/examples/Working_with_nonWebMercatorTMS.ipynb rename to docs/examples/notebooks/Working_with_nonWebMercatorTMS.ipynb diff --git a/docs/intro.md b/docs/intro.md index 43651f2b9..2c48e0d41 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -1,5 +1,5 @@ -`TiTiler` is a python module whose goal is to help users in creating a dynamic tile server. To learn more about `dynamic tiling` please refer to the [docs](/docs/concepts/dynamic_tiling.md). +`TiTiler` is a python module whose goal is to help users in creating a dynamic tile server. To learn more about `dynamic tiling` please refer to the [docs](dynamic_tiling.md). Users can choose to extend or use `Titiler` as it is. @@ -8,7 +8,7 @@ Users can choose to extend or use `Titiler` as it is. `TiTiler` comes with a default (complete) application with support for COG, STAC and MosaicJSON. You can start the application locally by doing: ```bash -$ pip install titiler[server] +$ pip install titiler $ uvicorn titiler.main:app --reload > INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) @@ -24,7 +24,7 @@ See default endpoints documentation pages: ## Customized, minimal app -`TiTiler` has been developed so users can build their own app using only the portions they need. Using [`TilerFactory`s](concepts/tiler_factories.md), users can create a fully customized application with only the endpoints needed. +`TiTiler` has been developed so users can build their own app using only the portions they need. Using [TilerFactories](advanced/tiler_factories.md), users can create a fully customized application with only the endpoints needed. ```python from titiler.endpoints.factory import TilerFactory @@ -72,4 +72,4 @@ app.include_router( ) ``` -More on [customization](concepts/customization.md) +More on [customization](advanced/customization.md) diff --git a/docs/concepts/mosaics.md b/docs/mosaics.md similarity index 89% rename from docs/concepts/mosaics.md rename to docs/mosaics.md index fa58329ad..1695bca95 100644 --- a/docs/concepts/mosaics.md +++ b/docs/mosaics.md @@ -1,5 +1,7 @@ -![](../img/africa_mosaic.png) +[Work in Progress] + +![](img/africa_mosaic.png) `Titiler` has native support for reading and creating web map tiles from **MosaicJSON**. @@ -7,7 +9,6 @@ Ref: https://github.com/developmentseed/mosaicjson-spec -[TODO] ### Links diff --git a/docs/concepts/output_format.md b/docs/output_format.md similarity index 95% rename from docs/concepts/output_format.md rename to docs/output_format.md index b3a8dd419..f32a87d81 100644 --- a/docs/concepts/output_format.md +++ b/docs/output_format.md @@ -47,4 +47,4 @@ print(data.shape) data, mask = data[0:-1], data[-1] ``` -See the notebook: [/examples/Working_with_NumpyTile.ipynb](/examples/Working_with_NumpyTile) +Notebook: [Working_with_NumpyTile](examples/notebooks/Working_with_NumpyTile.ipynb) diff --git a/docs/concepts/tile_matrix_sets.md b/docs/tile_matrix_sets.md similarity index 88% rename from docs/concepts/tile_matrix_sets.md rename to docs/tile_matrix_sets.md index cdc0c29ea..7c01cae29 100644 --- a/docs/concepts/tile_matrix_sets.md +++ b/docs/tile_matrix_sets.md @@ -32,7 +32,7 @@ Supported TMS: - WorldMercatorWGS84Quad ``` -You can easily add more TileMatrixSet support, see [custom-tms](concepts/customization/#custom-tms). +You can easily add more TileMatrixSet support, see [custom tms](advanced/customization.md#custom-tms). -Example: [Notebook](/examples/Working_with_nonWebMercatorTMS.ipynb) +Notebook: [Working_with_nonWebMercatorTMS](examples/notebooks/Working_with_nonWebMercatorTMS.ipynb) diff --git a/mkdocs.yml b/mkdocs.yml index 4aa12bfcd..034a19c26 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -19,10 +19,16 @@ nav: - TiTiler: "index.md" - User Guide: - Intro: "intro.md" - - Dynamic Tiling: "concepts/dynamic_tiling.md" - - Mosaics: "concepts/mosaics.md" - - TileMatrixSets: "concepts/tile_matrix_sets.md" - - Output data format: "concepts/output_format.md" + - Dynamic Tiling: "dynamic_tiling.md" + - Mosaics: "mosaics.md" + - TileMatrixSets: "tile_matrix_sets.md" + - Output data format: "output_format.md" + + - Advanced User Guide: + - Tiler Factories: "advanced/tiler_factories.md" + - Dependencies: "advanced/dependencies.md" + - Customization: "advanced/customization.md" + - APIRoute and environment variables: "advanced/APIRoute_and_environment_variables.md" - Default Endpoints: - /cog: "endpoints/cog.md" @@ -30,21 +36,22 @@ nav: - /mosaicjson: "endpoints/mosaic.md" - /tileMatrixSets: "endpoints/tms.md" - - Advanced User Guide: - - Tiler Factories: "concepts/tiler_factories.md" - - Dependencies: "concepts/dependencies.md" - - Customization: "concepts/customization.md" - - APIRoute and environment variables: "concepts/APIRoute_and_environment_variables.md" - - Examples: - - COG: "examples/Working_with_CloudOptimizedGeoTIFF_simple.ipynb" - - COG at scale: "examples/Working_with_CloudOptimizedGeoTIFF.ipynb" - - STAC: "examples/Working_with_STAC_simple.ipynb" - - STAC at scale: "examples/Working_with_STAC.ipynb" - - MosaicJSON: "examples/Working_with_MosaicJSON.ipynb" - - Non-WebMercator TMS: "examples/Working_with_nonWebMercatorTMS.ipynb" - - Custom Sentinel 2 Tiler: "examples/Create_CustomSentinel2Tiler.ipynb" - - NumpyTile: "examples/Working_with_NumpyTile.ipynb" + - Code: + - Minimal COG Tiler: "examples/code/mini_cog_tiler.md" + - Tiler with Auth: "examples/code/tiler_with_auth.md" + - Tiler with custom TMS: "examples/code/tiler_with_custom_tms.md" + - Tiler with Cache: "examples/code/tiler_with_cache.md" + + - Notebooks: + - COG: "examples/notebooks/Working_with_CloudOptimizedGeoTIFF_simple.ipynb" + - COG at scale: "examples/notebooks/Working_with_CloudOptimizedGeoTIFF.ipynb" + - STAC: "examples/notebooks/Working_with_STAC_simple.ipynb" + - STAC at scale: "examples/notebooks/Working_with_STAC.ipynb" + - MosaicJSON: "examples/notebooks/Working_with_MosaicJSON.ipynb" + - Non-WebMercator TMS: "examples/notebooks/Working_with_nonWebMercatorTMS.ipynb" + - Custom Sentinel 2 Tiler: "examples/notebooks/Create_CustomSentinel2Tiler.ipynb" + - NumpyTile: "examples/notebooks/Working_with_NumpyTile.ipynb" - API: - dependencies: api/titiler/dependencies.md @@ -52,6 +59,7 @@ nav: - middleware: api/titiler/middleware.md - utils: api/titiler/utils.md - enums: api/titiler/resources/enums.md + - errors: api/titiler/errors.md - Deployment: - Amazon Web Services: