Skip to content
Open
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
8 changes: 8 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Unreleased

### titiler.extensions

* fall back to WKT (with a warning) for the WMTS `BoundingBox` crs attribute when the CRS cannot be resolved to a URN (https://github.com/developmentseed/titiler/issues/1043)

### titiler.mosaic

* fall back to WKT (with a warning) for the WMTS `BoundingBox` crs attribute when the CRS cannot be resolved to a URN (https://github.com/developmentseed/titiler/issues/1043)

## 2.0.5 (2026-06-18)

## What's Changed
Expand Down
31 changes: 31 additions & 0 deletions src/titiler/extensions/tests/test_wmts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@

import io
import os
import xml.etree.ElementTree as ET

import pytest
import rasterio
from fastapi import FastAPI
from owslib.wmts import WebMapTileService
from rasterio.crs import CRS
from rio_tiler.utils import CRS_to_urn
from starlette.testclient import TestClient

from titiler.core.factory import TilerFactory
Expand Down Expand Up @@ -115,6 +119,33 @@ def test_wmtsExtension():
)


def test_wmtsExtension_crs_without_urn():
"""Test BoundingBox CRS fallback to WKT when the CRS cannot be resolved to a URN.

ref: https://github.com/developmentseed/titiler/issues/1043
"""
# Custom CRS (tweaked polar stereographic) which cannot be resolved to an authority
custom_crs = CRS.from_proj4(
"+proj=stere +lat_0=90 +lat_ts=71.3 +lon_0=-42.5 +x_0=0 +y_0=0 +a=6378137.1 +b=6356752.3 +units=m +no_defs"
)
assert CRS_to_urn(custom_crs) is None

tiler_plus_wmts = TilerFactory(extensions=[wmtsExtension(crs=custom_crs)])

app = FastAPI()
app.include_router(tiler_plus_wmts.router)
with TestClient(app) as client:
with pytest.warns(UserWarning, match="Could not resolve a URN for CRS"):
response = client.get(f"/WMTSCapabilities.xml?url={DATA_DIR}/cog.tif")
assert response.status_code == 200

root = ET.fromstring(response.content)
bboxes = root.findall(".//{http://www.opengis.net/ows/1.1}BoundingBox")
assert bboxes
for bbox in bboxes:
assert bbox.attrib["crs"] == custom_crs.to_wkt()


def test_wmtsExtension_with_renders():
"""Test wmtsExtension class with Renders."""
tiler_plus_wmts = TilerFactory(
Expand Down
10 changes: 9 additions & 1 deletion src/titiler/extensions/titiler/extensions/wmts.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,15 @@ def wmts( # noqa: C901
wmts_bbox = bounds
if geographic_crs != WGS84_CRS:
bbox_crs_type = "BoundingBox"
bbox_crs_uri = CRS_to_urn(geographic_crs) # type: ignore
crs_urn = CRS_to_urn(geographic_crs)
if not crs_urn:
warnings.warn(
f"Could not resolve a URN for CRS '{geographic_crs}', falling back to WKT for the BoundingBox crs attribute",
UserWarning,
stacklevel=2,
)
crs_urn = geographic_crs.to_wkt()
bbox_crs_uri = crs_urn
# WGS88BoundingBox is always xy ordered, but BoundingBox must match the CRS order
proj_crs = rio_crs_to_pyproj(geographic_crs)
if crs_axis_inverted(proj_crs):
Expand Down
36 changes: 36 additions & 0 deletions src/titiler/mosaic/tests/test_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
import tempfile
import xml.etree.ElementTree as ET
from contextlib import contextmanager
from dataclasses import dataclass
from io import BytesIO
Expand All @@ -11,12 +12,15 @@
import attr
import morecantile
import numpy
import pytest
from cogeo_mosaic.backends import FileBackend
from cogeo_mosaic.backends import MosaicBackend as MosaicJSONBackend
from cogeo_mosaic.mosaic import MosaicJSON
from fastapi import FastAPI, Query
from owslib.wmts import WebMapTileService
from rasterio.crs import CRS
from rio_tiler.mosaic.methods import PixelSelectionMethod
from rio_tiler.utils import CRS_to_urn
from starlette.testclient import TestClient

from titiler.core.dependencies import DefaultDependency
Expand Down Expand Up @@ -884,6 +888,38 @@ def test_wmts_extension_mosaic():
)


def test_wmts_extension_mosaic_crs_without_urn():
"""Test BoundingBox CRS fallback to WKT when the CRS cannot be resolved to a URN.

ref: https://github.com/developmentseed/titiler/issues/1043
"""
# Custom CRS (tweaked polar stereographic) which cannot be resolved to an authority
custom_crs = CRS.from_proj4(
"+proj=stere +lat_0=90 +lat_ts=71.3 +lon_0=-42.5 +x_0=0 +y_0=0 +a=6378137.1 +b=6356752.3 +units=m +no_defs"
)
assert CRS_to_urn(custom_crs) is None

mosaic = MosaicTilerFactory(
backend=MosaicJSONBackend,
extensions=[wmtsExtension(crs=custom_crs)],
)
app = FastAPI()
app.include_router(mosaic.router)
add_exception_handlers(app, MOSAIC_STATUS_CODES)

with TestClient(app) as client:
with tmpmosaic() as mosaic_file:
with pytest.warns(UserWarning, match="Could not resolve a URN for CRS"):
response = client.get(f"/WMTSCapabilities.xml?url={mosaic_file}")
assert response.status_code == 200

root = ET.fromstring(response.content)
bboxes = root.findall(".//{http://www.opengis.net/ows/1.1}BoundingBox")
assert bboxes
for bbox in bboxes:
assert bbox.attrib["crs"] == custom_crs.to_wkt()


def test_mosaic_statistics_featurecollection():
"""Test statistics endpoint with FeatureCollection containing multiple distinct features.

Expand Down
10 changes: 9 additions & 1 deletion src/titiler/mosaic/titiler/mosaic/extensions/wmts.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,15 @@ def wmts( # noqa: C901
wmts_bbox = bounds
if geographic_crs != WGS84_CRS:
bbox_crs_type = "BoundingBox"
bbox_crs_uri = CRS_to_urn(geographic_crs) # type: ignore
crs_urn = CRS_to_urn(geographic_crs)
if not crs_urn:
warnings.warn(
f"Could not resolve a URN for CRS '{geographic_crs}', falling back to WKT for the BoundingBox crs attribute",
UserWarning,
stacklevel=2,
)
crs_urn = geographic_crs.to_wkt()
bbox_crs_uri = crs_urn
# WGS88BoundingBox is always xy ordered, but BoundingBox must match the CRS order
proj_crs = rio_crs_to_pyproj(geographic_crs)
if crs_axis_inverted(proj_crs):
Expand Down
Loading