Skip to content

fix tiles extension #118

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

Merged
merged 5 commits into from
Mar 30, 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: 3 additions & 0 deletions stac_fastapi_extensions/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"stac-fastapi-types",
]

extras = {"tiles": ["titiler==0.2.*"]}

with open(
os.path.join(os.path.abspath(os.path.dirname(__file__)), "README.md")
) as readme_file:
Expand All @@ -36,6 +38,7 @@
py_modules=[splitext(basename(path))[0] for path in glob("stac_fastapi/*.py")],
include_package_data=False,
install_requires=install_requires,
extras_require=extras,
license="MIT",
keywords=["stac", "fastapi", "imagery", "raster", "catalog", "STAC"],
)
112 changes: 109 additions & 3 deletions stac_fastapi_extensions/stac_fastapi/extensions/third_party/tiles.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
"""tiles extension."""
import abc
from typing import List, Optional, Union
from urllib.parse import urljoin

import attr
from fastapi import FastAPI
from pydantic import BaseModel
from stac_pydantic.collection import SpatialExtent
from stac_pydantic.shared import Link
from stac_pydantic.shared import Link, MimeTypes, Relations
from starlette.requests import Request
from starlette.responses import HTMLResponse, RedirectResponse

from stac_fastapi.api.models import ItemUri
from stac_fastapi.api.routes import create_endpoint_with_depends
from stac_fastapi.types.core import BaseCoreClient
from stac_fastapi.types.extension import ApiExtension


Expand All @@ -30,6 +32,73 @@ class TileSetResource(BaseModel):
description: Optional[str]


@attr.s
class TileLinks:
"""Create inferred links specific to OGC Tiles API."""

base_url: str = attr.ib()
collection_id: str = attr.ib()
item_id: str = attr.ib()
route_prefix: str = attr.ib()

def __attrs_post_init__(self):
"""Post init handler."""
self.item_uri = urljoin(
self.base_url, f"/collections/{self.collection_id}/items/{self.item_id}"
)

def tiles(self) -> OGCTileLink:
"""Create tiles link."""
return OGCTileLink(
href=urljoin(
self.base_url,
f"{self.route_prefix}/tiles/{{z}}/{{x}}/{{y}}.png?url={self.item_uri}",
),
rel=Relations.item,
title="tiles",
type=MimeTypes.png,
templated=True,
)

def viewer(self) -> OGCTileLink:
"""Create viewer link."""
return OGCTileLink(
href=urljoin(
self.base_url, f"{self.route_prefix}/viewer?url={self.item_uri}"
),
rel=Relations.alternate,
type=MimeTypes.html,
title="viewer",
)

def tilejson(self) -> OGCTileLink:
"""Create tilejson link."""
return OGCTileLink(
href=urljoin(
self.base_url, f"{self.route_prefix}/tilejson.json?url={self.item_uri}"
),
rel=Relations.alternate,
type=MimeTypes.json,
title="tilejson",
)

def wmts(self) -> OGCTileLink:
"""Create wmts capabilities link."""
return OGCTileLink(
href=urljoin(
self.base_url,
f"{self.route_prefix}/WMTSCapabilities.xml?url={self.item_uri}",
),
rel=Relations.alternate,
type=MimeTypes.xml,
title="WMTS Capabilities",
)

def create_links(self) -> List[OGCTileLink]:
"""Return all inferred links."""
return [self.tiles(), self.tilejson(), self.wmts(), self.viewer()]


@attr.s
class BaseTilesClient(abc.ABC):
"""Defines a pattern for implementing the Tiles Extension."""
Expand All @@ -49,6 +118,42 @@ def get_item_tiles(
...


@attr.s
class TilesClient(BaseTilesClient):
"""Defines the default Tiles extension used by the application.

This extension should work with any backend that implements the `BaseCoreClient.get_item` method. If the accept
header is `text/html`, the endpoint will redirect to titiler's web viewer.
"""

client: BaseCoreClient = attr.ib()
route_prefix: str = attr.ib(default="/titiler")

def get_item_tiles(
self, id: str, **kwargs
) -> Union[RedirectResponse, TileSetResource]:
"""Get OGC TileSet resource for a stac item."""
item = self.client.get_item(id, **kwargs)
resource = TileSetResource(
extent=SpatialExtent(bbox=[list(item.bbox)]),
title=f"Tiled layer of {item.collection}/{item.id}",
links=TileLinks(
item_id=item.id,
collection_id=item.collection,
base_url=str(kwargs["request"].base_url),
route_prefix=self.route_prefix,
).create_links(),
)

if "text/html" in kwargs["request"].headers["accept"]:
viewer_url = [
link.href for link in resource.links if link.type == MimeTypes.html
][0]
return RedirectResponse(viewer_url)

return resource


@attr.s
class TilesExtension(ApiExtension):
"""Tiles Extension.
Expand All @@ -59,6 +164,7 @@ class TilesExtension(ApiExtension):
"""

client: BaseTilesClient = attr.ib()
route_prefix: str = attr.ib(default="/titiler")

def register(self, app: FastAPI) -> None:
"""Register the extension with a FastAPI application.
Expand All @@ -72,7 +178,7 @@ def register(self, app: FastAPI) -> None:
from titiler.endpoints.stac import STACTiler
from titiler.templates import templates

titiler_router = STACTiler().router
titiler_router = STACTiler(router_prefix=self.route_prefix).router

@titiler_router.get("/viewer", response_class=HTMLResponse)
def stac_demo(request: Request):
Expand All @@ -87,7 +193,7 @@ def stac_demo(request: Request):
media_type="text/html",
)

app.include_router(titiler_router, prefix="/titiler", tags=["Titiler"])
app.include_router(titiler_router, prefix=self.route_prefix, tags=["Titiler"])

app.add_api_route(
name="Get OGC Tiles Resource",
Expand Down
63 changes: 0 additions & 63 deletions stac_fastapi_sqlalchemy/stac_fastapi/sqlalchemy/models/links.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
import attr
from stac_pydantic.shared import Link, MimeTypes, Relations

from stac_fastapi.extensions.third_party.tiles import OGCTileLink

# These can be inferred from the item/collection so they aren't included in the database
# Instead they are dynamically generated when querying the database using the classes defined below
INFERRED_LINK_RELS = ["self", "item", "parent", "collection", "root"]
Expand Down Expand Up @@ -119,64 +117,3 @@ def create_links(self) -> List[Link]:
# TODO: Don't always append tiles link
links.append(self.tiles())
return links


@attr.s
class TileLinks:
"""Create inferred links specific to OGC Tiles API."""

base_url: str = attr.ib()
collection_id: str = attr.ib()
item_id: str = attr.ib()

def __post_init__(self):
"""Post init handler."""
self.item_uri = urljoin(
self.base_url, f"/collections/{self.collection_id}/items/{self.item_id}"
)

def tiles(self) -> OGCTileLink:
"""Create tiles link."""
return OGCTileLink(
href=urljoin(
self.base_url,
f"/titiler/tiles/{{z}}/{{x}}/{{y}}.png?url={self.item_uri}",
),
rel=Relations.item,
title="tiles",
type=MimeTypes.png,
templated=True,
)

def viewer(self) -> OGCTileLink:
"""Create viewer link."""
return OGCTileLink(
href=urljoin(self.base_url, f"/titiler/viewer?url={self.item_uri}"),
rel=Relations.alternate,
type=MimeTypes.html,
title="viewer",
)

def tilejson(self) -> OGCTileLink:
"""Create tilejson link."""
return OGCTileLink(
href=urljoin(self.base_url, f"/titiler/tilejson.json?url={self.item_uri}"),
rel=Relations.alternate,
type=MimeTypes.json,
title="tilejson",
)

def wmts(self) -> OGCTileLink:
"""Create wmts capabilities link."""
return OGCTileLink(
href=urljoin(
self.base_url, f"/titiler/WMTSCapabilities.xml?url={self.item_uri}"
),
rel=Relations.alternate,
type=MimeTypes.xml,
title="WMTS Capabilities",
)

def create_links(self) -> List[OGCTileLink]:
"""Return all inferred links."""
return [self.tiles(), self.tilejson(), self.wmts(), self.viewer()]
76 changes: 76 additions & 0 deletions tests/features/test_tiles_extension.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from urllib.parse import urlsplit

import pytest
from stac_pydantic import Collection, Item
from starlette.testclient import TestClient

from stac_fastapi.api.app import StacApi
from stac_fastapi.extensions.third_party.tiles import TilesClient, TilesExtension
from stac_fastapi.sqlalchemy.config import SqlalchemySettings

from ..conftest import MockStarletteRequest


@pytest.fixture
def tiles_extension_app(postgres_core, postgres_transactions, load_test_data):
# Ingest test data for testing
coll = Collection.parse_obj(load_test_data("test_collection.json"))
postgres_transactions.create_collection(coll, request=MockStarletteRequest)

item = Item.parse_obj(load_test_data("test_item.json"))
postgres_transactions.create_item(item, request=MockStarletteRequest)

settings = SqlalchemySettings()
api = StacApi(
settings=settings,
client=postgres_core,
extensions=[
TilesExtension(TilesClient(postgres_core)),
],
)
with TestClient(api.app) as test_app:
yield test_app

# Cleanup test data
postgres_transactions.delete_item(item.id, request=MockStarletteRequest)
postgres_transactions.delete_collection(coll.id, request=MockStarletteRequest)


def test_tiles_extension(tiles_extension_app, load_test_data):
item = load_test_data("test_item.json")

# Fetch the item
resp = tiles_extension_app.get(
f"/collections/{item['collection']}/items/{item['id']}"
)
resp_json = resp.json()
assert resp.status_code == 200

# Find the OGC tiles link
link = None
for link in resp_json["links"]:
if link.get("title") == "tiles":
break
assert link

# Request the TileSet resource
tiles_path = urlsplit(link["href"]).path
resp = tiles_extension_app.get(tiles_path)
assert resp.status_code == 200
tileset = resp.json()

assert tileset["extent"]["bbox"][0] == item["bbox"]

# We expect the tileset to have certain links
link_titles = [link["title"] for link in tileset["links"]]
assert "tiles" in link_titles
assert "tilejson" in link_titles
assert "viewer" in link_titles

# Confirm templated links are actually templates
for link in tileset["links"]:
if link.get("templated"):
# Since this is the `tile` extension checking against zoom seems reliable
assert "{z}" in link["href"]
else:
assert "{z}" not in link["href"]