Skip to content

Commit ea030af

Browse files
fix tiles extension (#118)
* add tiles extra to extensions setup.py with titiler dependency * move TileLinks to extensions package, bring back TilesClient, expose titiler route prefix * add tiles extension test case * also add route_prefix to the client, so we can use it for link generation
1 parent e98f0a3 commit ea030af

File tree

4 files changed

+188
-66
lines changed

4 files changed

+188
-66
lines changed

stac_fastapi_extensions/setup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
"stac-fastapi-types",
1919
]
2020

21+
extras = {"tiles": ["titiler==0.2.*"]}
22+
2123
with open(
2224
os.path.join(os.path.abspath(os.path.dirname(__file__)), "README.md")
2325
) as readme_file:
@@ -36,6 +38,7 @@
3638
py_modules=[splitext(basename(path))[0] for path in glob("stac_fastapi/*.py")],
3739
include_package_data=False,
3840
install_requires=install_requires,
41+
extras_require=extras,
3942
license="MIT",
4043
keywords=["stac", "fastapi", "imagery", "raster", "catalog", "STAC"],
4144
)

stac_fastapi_extensions/stac_fastapi/extensions/third_party/tiles.py

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
"""tiles extension."""
22
import abc
33
from typing import List, Optional, Union
4+
from urllib.parse import urljoin
45

56
import attr
67
from fastapi import FastAPI
78
from pydantic import BaseModel
89
from stac_pydantic.collection import SpatialExtent
9-
from stac_pydantic.shared import Link
10+
from stac_pydantic.shared import Link, MimeTypes, Relations
1011
from starlette.requests import Request
1112
from starlette.responses import HTMLResponse, RedirectResponse
1213

1314
from stac_fastapi.api.models import ItemUri
1415
from stac_fastapi.api.routes import create_endpoint_with_depends
16+
from stac_fastapi.types.core import BaseCoreClient
1517
from stac_fastapi.types.extension import ApiExtension
1618

1719

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

3234

35+
@attr.s
36+
class TileLinks:
37+
"""Create inferred links specific to OGC Tiles API."""
38+
39+
base_url: str = attr.ib()
40+
collection_id: str = attr.ib()
41+
item_id: str = attr.ib()
42+
route_prefix: str = attr.ib()
43+
44+
def __attrs_post_init__(self):
45+
"""Post init handler."""
46+
self.item_uri = urljoin(
47+
self.base_url, f"/collections/{self.collection_id}/items/{self.item_id}"
48+
)
49+
50+
def tiles(self) -> OGCTileLink:
51+
"""Create tiles link."""
52+
return OGCTileLink(
53+
href=urljoin(
54+
self.base_url,
55+
f"{self.route_prefix}/tiles/{{z}}/{{x}}/{{y}}.png?url={self.item_uri}",
56+
),
57+
rel=Relations.item,
58+
title="tiles",
59+
type=MimeTypes.png,
60+
templated=True,
61+
)
62+
63+
def viewer(self) -> OGCTileLink:
64+
"""Create viewer link."""
65+
return OGCTileLink(
66+
href=urljoin(
67+
self.base_url, f"{self.route_prefix}/viewer?url={self.item_uri}"
68+
),
69+
rel=Relations.alternate,
70+
type=MimeTypes.html,
71+
title="viewer",
72+
)
73+
74+
def tilejson(self) -> OGCTileLink:
75+
"""Create tilejson link."""
76+
return OGCTileLink(
77+
href=urljoin(
78+
self.base_url, f"{self.route_prefix}/tilejson.json?url={self.item_uri}"
79+
),
80+
rel=Relations.alternate,
81+
type=MimeTypes.json,
82+
title="tilejson",
83+
)
84+
85+
def wmts(self) -> OGCTileLink:
86+
"""Create wmts capabilities link."""
87+
return OGCTileLink(
88+
href=urljoin(
89+
self.base_url,
90+
f"{self.route_prefix}/WMTSCapabilities.xml?url={self.item_uri}",
91+
),
92+
rel=Relations.alternate,
93+
type=MimeTypes.xml,
94+
title="WMTS Capabilities",
95+
)
96+
97+
def create_links(self) -> List[OGCTileLink]:
98+
"""Return all inferred links."""
99+
return [self.tiles(), self.tilejson(), self.wmts(), self.viewer()]
100+
101+
33102
@attr.s
34103
class BaseTilesClient(abc.ABC):
35104
"""Defines a pattern for implementing the Tiles Extension."""
@@ -49,6 +118,42 @@ def get_item_tiles(
49118
...
50119

51120

121+
@attr.s
122+
class TilesClient(BaseTilesClient):
123+
"""Defines the default Tiles extension used by the application.
124+
125+
This extension should work with any backend that implements the `BaseCoreClient.get_item` method. If the accept
126+
header is `text/html`, the endpoint will redirect to titiler's web viewer.
127+
"""
128+
129+
client: BaseCoreClient = attr.ib()
130+
route_prefix: str = attr.ib(default="/titiler")
131+
132+
def get_item_tiles(
133+
self, id: str, **kwargs
134+
) -> Union[RedirectResponse, TileSetResource]:
135+
"""Get OGC TileSet resource for a stac item."""
136+
item = self.client.get_item(id, **kwargs)
137+
resource = TileSetResource(
138+
extent=SpatialExtent(bbox=[list(item.bbox)]),
139+
title=f"Tiled layer of {item.collection}/{item.id}",
140+
links=TileLinks(
141+
item_id=item.id,
142+
collection_id=item.collection,
143+
base_url=str(kwargs["request"].base_url),
144+
route_prefix=self.route_prefix,
145+
).create_links(),
146+
)
147+
148+
if "text/html" in kwargs["request"].headers["accept"]:
149+
viewer_url = [
150+
link.href for link in resource.links if link.type == MimeTypes.html
151+
][0]
152+
return RedirectResponse(viewer_url)
153+
154+
return resource
155+
156+
52157
@attr.s
53158
class TilesExtension(ApiExtension):
54159
"""Tiles Extension.
@@ -59,6 +164,7 @@ class TilesExtension(ApiExtension):
59164
"""
60165

61166
client: BaseTilesClient = attr.ib()
167+
route_prefix: str = attr.ib(default="/titiler")
62168

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

75-
titiler_router = STACTiler().router
181+
titiler_router = STACTiler(router_prefix=self.route_prefix).router
76182

77183
@titiler_router.get("/viewer", response_class=HTMLResponse)
78184
def stac_demo(request: Request):
@@ -87,7 +193,7 @@ def stac_demo(request: Request):
87193
media_type="text/html",
88194
)
89195

90-
app.include_router(titiler_router, prefix="/titiler", tags=["Titiler"])
196+
app.include_router(titiler_router, prefix=self.route_prefix, tags=["Titiler"])
91197

92198
app.add_api_route(
93199
name="Get OGC Tiles Resource",

stac_fastapi_sqlalchemy/stac_fastapi/sqlalchemy/models/links.py

Lines changed: 0 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
import attr
77
from stac_pydantic.shared import Link, MimeTypes, Relations
88

9-
from stac_fastapi.extensions.third_party.tiles import OGCTileLink
10-
119
# These can be inferred from the item/collection so they aren't included in the database
1210
# Instead they are dynamically generated when querying the database using the classes defined below
1311
INFERRED_LINK_RELS = ["self", "item", "parent", "collection", "root"]
@@ -119,64 +117,3 @@ def create_links(self) -> List[Link]:
119117
# TODO: Don't always append tiles link
120118
links.append(self.tiles())
121119
return links
122-
123-
124-
@attr.s
125-
class TileLinks:
126-
"""Create inferred links specific to OGC Tiles API."""
127-
128-
base_url: str = attr.ib()
129-
collection_id: str = attr.ib()
130-
item_id: str = attr.ib()
131-
132-
def __post_init__(self):
133-
"""Post init handler."""
134-
self.item_uri = urljoin(
135-
self.base_url, f"/collections/{self.collection_id}/items/{self.item_id}"
136-
)
137-
138-
def tiles(self) -> OGCTileLink:
139-
"""Create tiles link."""
140-
return OGCTileLink(
141-
href=urljoin(
142-
self.base_url,
143-
f"/titiler/tiles/{{z}}/{{x}}/{{y}}.png?url={self.item_uri}",
144-
),
145-
rel=Relations.item,
146-
title="tiles",
147-
type=MimeTypes.png,
148-
templated=True,
149-
)
150-
151-
def viewer(self) -> OGCTileLink:
152-
"""Create viewer link."""
153-
return OGCTileLink(
154-
href=urljoin(self.base_url, f"/titiler/viewer?url={self.item_uri}"),
155-
rel=Relations.alternate,
156-
type=MimeTypes.html,
157-
title="viewer",
158-
)
159-
160-
def tilejson(self) -> OGCTileLink:
161-
"""Create tilejson link."""
162-
return OGCTileLink(
163-
href=urljoin(self.base_url, f"/titiler/tilejson.json?url={self.item_uri}"),
164-
rel=Relations.alternate,
165-
type=MimeTypes.json,
166-
title="tilejson",
167-
)
168-
169-
def wmts(self) -> OGCTileLink:
170-
"""Create wmts capabilities link."""
171-
return OGCTileLink(
172-
href=urljoin(
173-
self.base_url, f"/titiler/WMTSCapabilities.xml?url={self.item_uri}"
174-
),
175-
rel=Relations.alternate,
176-
type=MimeTypes.xml,
177-
title="WMTS Capabilities",
178-
)
179-
180-
def create_links(self) -> List[OGCTileLink]:
181-
"""Return all inferred links."""
182-
return [self.tiles(), self.tilejson(), self.wmts(), self.viewer()]
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from urllib.parse import urlsplit
2+
3+
import pytest
4+
from stac_pydantic import Collection, Item
5+
from starlette.testclient import TestClient
6+
7+
from stac_fastapi.api.app import StacApi
8+
from stac_fastapi.extensions.third_party.tiles import TilesClient, TilesExtension
9+
from stac_fastapi.sqlalchemy.config import SqlalchemySettings
10+
11+
from ..conftest import MockStarletteRequest
12+
13+
14+
@pytest.fixture
15+
def tiles_extension_app(postgres_core, postgres_transactions, load_test_data):
16+
# Ingest test data for testing
17+
coll = Collection.parse_obj(load_test_data("test_collection.json"))
18+
postgres_transactions.create_collection(coll, request=MockStarletteRequest)
19+
20+
item = Item.parse_obj(load_test_data("test_item.json"))
21+
postgres_transactions.create_item(item, request=MockStarletteRequest)
22+
23+
settings = SqlalchemySettings()
24+
api = StacApi(
25+
settings=settings,
26+
client=postgres_core,
27+
extensions=[
28+
TilesExtension(TilesClient(postgres_core)),
29+
],
30+
)
31+
with TestClient(api.app) as test_app:
32+
yield test_app
33+
34+
# Cleanup test data
35+
postgres_transactions.delete_item(item.id, request=MockStarletteRequest)
36+
postgres_transactions.delete_collection(coll.id, request=MockStarletteRequest)
37+
38+
39+
def test_tiles_extension(tiles_extension_app, load_test_data):
40+
item = load_test_data("test_item.json")
41+
42+
# Fetch the item
43+
resp = tiles_extension_app.get(
44+
f"/collections/{item['collection']}/items/{item['id']}"
45+
)
46+
resp_json = resp.json()
47+
assert resp.status_code == 200
48+
49+
# Find the OGC tiles link
50+
link = None
51+
for link in resp_json["links"]:
52+
if link.get("title") == "tiles":
53+
break
54+
assert link
55+
56+
# Request the TileSet resource
57+
tiles_path = urlsplit(link["href"]).path
58+
resp = tiles_extension_app.get(tiles_path)
59+
assert resp.status_code == 200
60+
tileset = resp.json()
61+
62+
assert tileset["extent"]["bbox"][0] == item["bbox"]
63+
64+
# We expect the tileset to have certain links
65+
link_titles = [link["title"] for link in tileset["links"]]
66+
assert "tiles" in link_titles
67+
assert "tilejson" in link_titles
68+
assert "viewer" in link_titles
69+
70+
# Confirm templated links are actually templates
71+
for link in tileset["links"]:
72+
if link.get("templated"):
73+
# Since this is the `tile` extension checking against zoom seems reliable
74+
assert "{z}" in link["href"]
75+
else:
76+
assert "{z}" not in link["href"]

0 commit comments

Comments
 (0)