From 3dd8eb52e22172c2770e848106f6f2d04724cb6d Mon Sep 17 00:00:00 2001 From: Jeff Albrecht Date: Mon, 29 Mar 2021 20:12:38 -0500 Subject: [PATCH 1/4] add tiles extra to extensions setup.py with titiler dependency --- stac_fastapi_extensions/setup.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/stac_fastapi_extensions/setup.py b/stac_fastapi_extensions/setup.py index ca26995ff..746cfab8e 100644 --- a/stac_fastapi_extensions/setup.py +++ b/stac_fastapi_extensions/setup.py @@ -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: @@ -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"], ) From 8d93a8efbf43c0a8a5bac474da48ef1fa461c9cf Mon Sep 17 00:00:00 2001 From: Jeff Albrecht Date: Mon, 29 Mar 2021 20:16:36 -0500 Subject: [PATCH 2/4] move TileLinks to extensions package, bring back TilesClient, expose titiler route prefix --- .../extensions/third_party/tiles.py | 104 +++++++++++++++++- .../stac_fastapi/sqlalchemy/models/links.py | 63 ----------- 2 files changed, 101 insertions(+), 66 deletions(-) diff --git a/stac_fastapi_extensions/stac_fastapi/extensions/third_party/tiles.py b/stac_fastapi_extensions/stac_fastapi/extensions/third_party/tiles.py index f8f457601..e6f44e53d 100644 --- a/stac_fastapi_extensions/stac_fastapi/extensions/third_party/tiles.py +++ b/stac_fastapi_extensions/stac_fastapi/extensions/third_party/tiles.py @@ -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 @@ -30,6 +32,67 @@ 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() + + 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"/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()] + + @attr.s class BaseTilesClient(abc.ABC): """Defines a pattern for implementing the Tiles Extension.""" @@ -49,6 +112,40 @@ 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() + + 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), + ).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. @@ -59,6 +156,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. @@ -72,7 +170,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): @@ -87,7 +185,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", diff --git a/stac_fastapi_sqlalchemy/stac_fastapi/sqlalchemy/models/links.py b/stac_fastapi_sqlalchemy/stac_fastapi/sqlalchemy/models/links.py index db1828138..70097db97 100644 --- a/stac_fastapi_sqlalchemy/stac_fastapi/sqlalchemy/models/links.py +++ b/stac_fastapi_sqlalchemy/stac_fastapi/sqlalchemy/models/links.py @@ -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"] @@ -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()] From 223860c42311a9399c49a115f45aab20ed543200 Mon Sep 17 00:00:00 2001 From: Jeff Albrecht Date: Mon, 29 Mar 2021 20:58:35 -0500 Subject: [PATCH 3/4] add tiles extension test case --- tests/features/test_tiles_extension.py | 76 ++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 tests/features/test_tiles_extension.py diff --git a/tests/features/test_tiles_extension.py b/tests/features/test_tiles_extension.py new file mode 100644 index 000000000..aadbfa466 --- /dev/null +++ b/tests/features/test_tiles_extension.py @@ -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"] From 38517570ce07ad142d31c97401808eb505b02abb Mon Sep 17 00:00:00 2001 From: Jeff Albrecht Date: Mon, 29 Mar 2021 21:12:13 -0500 Subject: [PATCH 4/4] also add route_prefix to the client, so we can use it for link generation --- .../stac_fastapi/extensions/third_party/tiles.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/stac_fastapi_extensions/stac_fastapi/extensions/third_party/tiles.py b/stac_fastapi_extensions/stac_fastapi/extensions/third_party/tiles.py index e6f44e53d..524f840ac 100644 --- a/stac_fastapi_extensions/stac_fastapi/extensions/third_party/tiles.py +++ b/stac_fastapi_extensions/stac_fastapi/extensions/third_party/tiles.py @@ -39,6 +39,7 @@ class TileLinks: 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.""" @@ -51,7 +52,7 @@ def tiles(self) -> OGCTileLink: return OGCTileLink( href=urljoin( self.base_url, - f"/titiler/tiles/{{z}}/{{x}}/{{y}}.png?url={self.item_uri}", + f"{self.route_prefix}/tiles/{{z}}/{{x}}/{{y}}.png?url={self.item_uri}", ), rel=Relations.item, title="tiles", @@ -62,7 +63,9 @@ def tiles(self) -> OGCTileLink: def viewer(self) -> OGCTileLink: """Create viewer link.""" return OGCTileLink( - href=urljoin(self.base_url, f"/titiler/viewer?url={self.item_uri}"), + href=urljoin( + self.base_url, f"{self.route_prefix}/viewer?url={self.item_uri}" + ), rel=Relations.alternate, type=MimeTypes.html, title="viewer", @@ -71,7 +74,9 @@ def viewer(self) -> OGCTileLink: def tilejson(self) -> OGCTileLink: """Create tilejson link.""" return OGCTileLink( - href=urljoin(self.base_url, f"/titiler/tilejson.json?url={self.item_uri}"), + href=urljoin( + self.base_url, f"{self.route_prefix}/tilejson.json?url={self.item_uri}" + ), rel=Relations.alternate, type=MimeTypes.json, title="tilejson", @@ -81,7 +86,8 @@ def wmts(self) -> OGCTileLink: """Create wmts capabilities link.""" return OGCTileLink( href=urljoin( - self.base_url, f"/titiler/WMTSCapabilities.xml?url={self.item_uri}" + self.base_url, + f"{self.route_prefix}/WMTSCapabilities.xml?url={self.item_uri}", ), rel=Relations.alternate, type=MimeTypes.xml, @@ -121,6 +127,7 @@ class TilesClient(BaseTilesClient): """ client: BaseCoreClient = attr.ib() + route_prefix: str = attr.ib(default="/titiler") def get_item_tiles( self, id: str, **kwargs @@ -134,6 +141,7 @@ def get_item_tiles( item_id=item.id, collection_id=item.collection, base_url=str(kwargs["request"].base_url), + route_prefix=self.route_prefix, ).create_links(), )