diff --git a/CHANGES.md b/CHANGES.md index 78c16e215..2a32588ab 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 diff --git a/src/titiler/extensions/tests/test_wmts.py b/src/titiler/extensions/tests/test_wmts.py index a649d76f4..1015bf773 100644 --- a/src/titiler/extensions/tests/test_wmts.py +++ b/src/titiler/extensions/tests/test_wmts.py @@ -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 @@ -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( diff --git a/src/titiler/extensions/titiler/extensions/wmts.py b/src/titiler/extensions/titiler/extensions/wmts.py index d84805a47..d0a1b6570 100644 --- a/src/titiler/extensions/titiler/extensions/wmts.py +++ b/src/titiler/extensions/titiler/extensions/wmts.py @@ -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): diff --git a/src/titiler/mosaic/tests/test_factory.py b/src/titiler/mosaic/tests/test_factory.py index 24d500639..df79f423f 100644 --- a/src/titiler/mosaic/tests/test_factory.py +++ b/src/titiler/mosaic/tests/test_factory.py @@ -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 @@ -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 @@ -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. diff --git a/src/titiler/mosaic/titiler/mosaic/extensions/wmts.py b/src/titiler/mosaic/titiler/mosaic/extensions/wmts.py index 63f4d4465..a32a0ce2e 100644 --- a/src/titiler/mosaic/titiler/mosaic/extensions/wmts.py +++ b/src/titiler/mosaic/titiler/mosaic/extensions/wmts.py @@ -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):