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

add method to add more dependencies to specific endpoints #406

Merged
merged 5 commits into from
Nov 24, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
# Release Notes

## 0.4.0a2 (TBD)
## 0.4.0a2 (2021-11-24)

### titiler.core

* update `rio-tiler` version (>=3.0.0a6) with new colormap types information and base classes
* 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

Expand Down
1 change: 1 addition & 0 deletions src/titiler/core/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
101 changes: 100 additions & 1 deletion src/titiler/core/tests/test_factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
48 changes: 46 additions & 2 deletions src/titiler/core/titiler/core/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand All @@ -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:
vincentsarago marked this conversation as resolved.
Show resolved Hide resolved
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
alukach marked this conversation as resolved.
Show resolved Hide resolved
0,
get_parameterless_sub_dependant(
depends=depends, path=route.path_format # type: ignore
),
)
vincentsarago marked this conversation as resolved.
Show resolved Hide resolved

alukach marked this conversation as resolved.
Show resolved Hide resolved
# 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):
Expand Down
55 changes: 54 additions & 1 deletion src/titiler/core/titiler/core/routing.py
Original file line number Diff line number Diff line change
@@ -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]:
Expand Down Expand Up @@ -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