diff --git a/CHANGES.md b/CHANGES.md index 84294fc7d..b6a380c6a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,6 @@ # Release Notes -## 0.4.0a2 (TBD) +## 0.4.0a2 (2021-11-24) ### titiler.core @@ -8,6 +8,7 @@ * remove `additional_dependency` attribute in `BaseTileFactory`. This also remove `**kwargs` in endpoints **breaking** * remove `reader_options` attribute in `BaseTileFactory` **breaking** * `tms_dependency` default to `titiler.core.dependencies.TMSParams` which should supports all morecantile's TMS. +* add `route_dependencies` attribute to `BaseTilerFactory` to allow customizing route dependencies (author @alukach, https://github.com/developmentseed/titiler/pull/406) ### titiler.mosaic diff --git a/src/titiler/core/setup.py b/src/titiler/core/setup.py index dfeb6d4c4..ec6b47429 100644 --- a/src/titiler/core/setup.py +++ b/src/titiler/core/setup.py @@ -15,6 +15,7 @@ "rio-tiler>=3.0.0a6,<3.1", "simplejson", "importlib_resources>=1.1.0;python_version<'3.9'", + "typing_extensions;python_version<'3.8'", ] extra_reqs = { "test": ["pytest", "pytest-cov", "pytest-asyncio", "requests"], diff --git a/src/titiler/core/tests/test_factories.py b/src/titiler/core/tests/test_factories.py index 7a4fd1b2c..472ac3055 100644 --- a/src/titiler/core/tests/test_factories.py +++ b/src/titiler/core/tests/test_factories.py @@ -9,6 +9,7 @@ import attr import morecantile +from requests.auth import HTTPBasicAuth from rio_tiler.io import BaseReader, COGReader, MultiBandReader, STACReader from titiler.core.dependencies import TMSParams, WebMercatorTMSParams @@ -23,7 +24,7 @@ from .conftest import DATA_DIR, mock_rasterio_open, parse_img -from fastapi import FastAPI, Query +from fastapi import Depends, FastAPI, HTTPException, Query, security, status from starlette.testclient import TestClient @@ -1131,3 +1132,101 @@ def test_TMSFactory(): body = response.json() assert body["type"] == "TileMatrixSetType" assert body["identifier"] == "WebMercatorQuad" + + +def test_TilerFactory_WithDependencies(): + """Test TilerFactory class.""" + + http_basic = security.HTTPBasic() + + def must_be_bob(credentials: security.HTTPBasicCredentials = Depends(http_basic)): + if credentials.username == "bob": + return True + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="You're not Bob", + headers={"WWW-Authenticate": "Basic"}, + ) + + cog = TilerFactory( + route_dependencies=[ + ( + [ + {"path": "/bounds", "method": "GET"}, + {"path": "/tiles/{z}/{x}/{y}", "method": "GET"}, + ], + [Depends(must_be_bob)], + ), + ], + router_prefix="something", + ) + assert len(cog.router.routes) == 25 + assert cog.tms_dependency == TMSParams + + app = FastAPI() + app.include_router(cog.router, prefix="/something") + client = TestClient(app) + + auth_bob = HTTPBasicAuth(username="bob", password="ILoveSponge") + auth_notbob = HTTPBasicAuth(username="notbob", password="IHateSponge") + + response = client.get(f"/something/tilejson.json?url={DATA_DIR}/cog.tif") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert response.json()["tilejson"] + + response = client.get( + f"/something/bounds?url={DATA_DIR}/cog.tif&rescale=0,1000", auth=auth_bob + ) + assert response.status_code == 200 + + response = client.get( + f"/something/bounds?url={DATA_DIR}/cog.tif&rescale=0,1000", auth=auth_notbob + ) + assert response.status_code == 401 + assert response.json()["detail"] == "You're not Bob" + + response = client.get( + f"/something/tiles/8/87/48?url={DATA_DIR}/cog.tif&rescale=0,1000", auth=auth_bob + ) + assert response.status_code == 200 + assert response.headers["content-type"] == "image/jpeg" + + response = client.get( + f"/something/tiles/8/87/48?url={DATA_DIR}/cog.tif&rescale=0,1000", + auth=auth_notbob, + ) + assert response.status_code == 401 + assert response.json()["detail"] == "You're not Bob" + + response = client.get( + f"/something/tiles/8/87/48.jpeg?url={DATA_DIR}/cog.tif&rescale=0,1000" + ) + assert response.status_code == 200 + assert response.headers["content-type"] == "image/jpeg" + + cog = TilerFactory(router_prefix="something") + cog.add_route_dependencies( + scopes=[{"path": "/bounds", "method": "GET"}], + dependencies=[Depends(must_be_bob)], + ) + + app = FastAPI() + app.include_router(cog.router, prefix="/something") + client = TestClient(app) + + response = client.get(f"/something/tilejson.json?url={DATA_DIR}/cog.tif") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert response.json()["tilejson"] + + response = client.get( + f"/something/bounds?url={DATA_DIR}/cog.tif&rescale=0,1000", auth=auth_bob + ) + assert response.status_code == 200 + + response = client.get( + f"/something/bounds?url={DATA_DIR}/cog.tif&rescale=0,1000", auth=auth_notbob + ) + assert response.status_code == 401 + assert response.json()["detail"] == "You're not Bob" diff --git a/src/titiler/core/tests/test_CustomAPIRoute.py b/src/titiler/core/tests/test_routing.py similarity index 71% rename from src/titiler/core/tests/test_CustomAPIRoute.py rename to src/titiler/core/tests/test_routing.py index a900c697f..05a3444c9 100644 --- a/src/titiler/core/tests/test_CustomAPIRoute.py +++ b/src/titiler/core/tests/test_routing.py @@ -5,10 +5,11 @@ import pytest import rasterio from rasterio._env import get_gdal_config +from requests.auth import HTTPBasicAuth -from titiler.core.routing import apiroute_factory +from titiler.core.routing import add_route_dependencies, apiroute_factory -from fastapi import APIRouter, FastAPI +from fastapi import APIRouter, Depends, FastAPI, HTTPException, security, status from starlette.testclient import TestClient @@ -126,3 +127,55 @@ async def home3(): response = client.get("/afuture") assert response.json()["env"] == "FALSE" + + +def test_register_deps(): + """Test add_route_dependencies.""" + + http_basic = security.HTTPBasic() + + def must_be_bob(credentials: security.HTTPBasicCredentials = Depends(http_basic)): + if credentials.username == "bob": + return True + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="You're not Bob", + headers={"WWW-Authenticate": "Basic"}, + ) + + app = FastAPI() + + @app.get("/one") + def one(): + """one.""" + return "one" + + @app.get("/two") + def two(): + """two.""" + return "two" + + auth_bob = HTTPBasicAuth(username="bob", password="ILoveSponge") + auth_notbob = HTTPBasicAuth(username="notbob", password="IHateSponge") + + add_route_dependencies( + app.routes, + scopes=[ + {"path": "/one", "method": "GET"}, + ], + dependencies=[Depends(must_be_bob)], + ) + + client = TestClient(app) + + response = client.get("/one", auth=auth_bob) + assert response.status_code == 200 + + response = client.get("/one", auth=auth_notbob) + assert response.status_code == 401 + + response = client.get("/two", auth=auth_bob) + assert response.status_code == 200 + + response = client.get("/two", auth=auth_notbob) + assert response.status_code == 200 diff --git a/src/titiler/core/titiler/core/factory.py b/src/titiler/core/titiler/core/factory.py index 91d49af5a..bd0942674 100644 --- a/src/titiler/core/titiler/core/factory.py +++ b/src/titiler/core/titiler/core/factory.py @@ -2,7 +2,7 @@ import abc from dataclasses import dataclass, field -from typing import Any, Callable, Dict, List, Optional, Type, Union +from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union from urllib.parse import urlencode import rasterio @@ -48,12 +48,15 @@ ) from titiler.core.resources.enums import ImageType, MediaType, OptionalHeader from titiler.core.resources.responses import GeoJSONResponse, JSONResponse, XMLResponse +from titiler.core.routing import EndpointScope from titiler.core.utils import Timer -from fastapi import APIRouter, Body, Depends, Path, Query +from fastapi import APIRouter, Body, Depends, Path, Query, params +from fastapi.dependencies.utils import get_parameterless_sub_dependant from starlette.requests import Request from starlette.responses import Response +from starlette.routing import Match from starlette.templating import Jinja2Templates try: @@ -149,10 +152,18 @@ class BaseTilerFactory(metaclass=abc.ABCMeta): # add additional headers in response optional_headers: List[OptionalHeader] = field(default_factory=list) + # add dependencies to specific routes + route_dependencies: List[Tuple[List[EndpointScope], List[params.Depends]]] = field( + default_factory=list + ) + def __post_init__(self): """Post Init: register route and configure specific options.""" self.register_routes() + for scopes, dependencies in self.route_dependencies: + self.add_route_dependencies(scopes=scopes, dependencies=dependencies) + @abc.abstractmethod def register_routes(self): """Register Tiler Routes.""" @@ -166,6 +177,39 @@ def url_for(self, request: Request, name: str, **path_params: Any) -> str: base_url += self.router_prefix.lstrip("/") return url_path.make_absolute_url(base_url=base_url) + def add_route_dependencies( + self, + *, + scopes: List[EndpointScope], + dependencies=List[params.Depends], + ): + """Add dependencies to routes. + + Allows a developer to add dependencies to a route after the route has been defined. + + """ + for route in self.router.routes: + for scope in scopes: + match, _ = route.matches({"type": "http", **scope}) + if match != Match.FULL: + continue + + # Mimicking how APIRoute handles dependencies: + # https://github.com/tiangolo/fastapi/blob/1760da0efa55585c19835d81afa8ca386036c325/fastapi/routing.py#L408-L412 + for depends in dependencies[::-1]: + route.dependant.dependencies.insert( # type: ignore + 0, + get_parameterless_sub_dependant( + depends=depends, path=route.path_format # type: ignore + ), + ) + + # Register dependencies directly on route so that they aren't ignored if + # the routes are later associated with an app (e.g. app.include_router(router)) + # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360 + # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678 + route.dependencies.extend(dependencies) # type: ignore + @dataclass class TilerFactory(BaseTilerFactory): diff --git a/src/titiler/core/titiler/core/routing.py b/src/titiler/core/titiler/core/routing.py index 318ea66ec..98cb8ba50 100644 --- a/src/titiler/core/titiler/core/routing.py +++ b/src/titiler/core/titiler/core/routing.py @@ -1,14 +1,23 @@ """Custom routing classes.""" +import sys import warnings -from typing import Callable, Dict, Optional, Type +from typing import Callable, Dict, List, Optional, Type import rasterio +from fastapi import params +from fastapi.dependencies.utils import get_parameterless_sub_dependant from fastapi.routing import APIRoute from starlette.requests import Request from starlette.responses import Response +from starlette.routing import BaseRoute, Match + +if sys.version_info >= (3, 8): + from typing import TypedDict # pylint: disable=no-name-in-module +else: + from typing_extensions import TypedDict def apiroute_factory(env: Optional[Dict] = None) -> Type[APIRoute]: @@ -45,3 +54,47 @@ async def custom_route_handler(request: Request) -> Response: return custom_route_handler return EnvAPIRoute + + +class EndpointScope(TypedDict, total=False): + """Define endpoint.""" + + # More strict version of Starlette's Scope + # https://github.com/encode/starlette/blob/6af5c515e0a896cbf3f86ee043b88f6c24200bcf/starlette/types.py#L3 + path: str + method: str + type: Optional[str] # http or websocket + + +def add_route_dependencies( + routes: List[BaseRoute], + *, + scopes: List[EndpointScope], + dependencies=List[params.Depends], +): + """Add dependencies to routes. + + Allows a developer to add dependencies to a route after the route has been defined. + + """ + for route in routes: + for scope in scopes: + match, _ = route.matches({"type": "http", **scope}) # type: ignore + if match != Match.FULL: + continue + + # Mimicking how APIRoute handles dependencies: + # https://github.com/tiangolo/fastapi/blob/1760da0efa55585c19835d81afa8ca386036c325/fastapi/routing.py#L408-L412 + for depends in dependencies[::-1]: + route.dependant.dependencies.insert( # type: ignore + 0, + get_parameterless_sub_dependant( + depends=depends, path=route.path_format # type: ignore + ), + ) + + # Register dependencies directly on route so that they aren't ignored if + # the routes are later associated with an app (e.g. app.include_router(router)) + # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360 + # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678 + route.dependencies.extend(dependencies) # type: ignore