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
27 changes: 27 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,33 @@

## Unreleased

### Misc

* add: return PROJJSON CRS in headers (`Content-Crs-JSON`) when `OptionalHeader.projjson_crs` is set

```python
endpoints = TilerFactory(optional_headers=[OptionalHeader.projjson_crs])

app = FastAPI()
app.include_router(endpoints.router)
with TestClient(app) as client:
response = client.get(
"/preview.png",
params={
"url": cog_path,
},
)
headers = response.headers
assert "content-crs-json" in headers
projjson_crs = json.loads(headers["content-crs-json"])
assert CRS.from_user_input(projjson_crs).to_epsg() == 32621
```

### titiler.core

* add: `optional_headers` attribute to `TilerFactory` class
* add: `projjson_crs` to `OptionalHeader` enum

## 1.1.1 (2026-01-22)

### titiler.extensions
Expand Down
23 changes: 23 additions & 0 deletions src/titiler/core/tests/test_factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
TilerFactory,
TMSFactory,
)
from titiler.core.resources.enums import OptionalHeader

from .conftest import DATA_DIR, mock_rasterio_open, parse_img

Expand Down Expand Up @@ -2233,3 +2234,25 @@ def test_ogc_maps_cog():
assert headers["Content-Crs"] == "<http://www.opengis.net/def/crs/EPSG/0/4326>"
assert headers["Content-Bbox"] == "-56.228,72.715,-54.54699999999999,73.188"
assert headers["content-type"] == "image/png"


def test_optional_headers():
"""Test TilerFactory class."""
cog_path = f"{DATA_DIR}/cog.tif"

cog = TilerFactory(optional_headers=[OptionalHeader.projjson_crs])

app = FastAPI()
app.include_router(cog.router)
with TestClient(app) as client:
response = client.get(
"/preview.png",
params={
"url": cog_path,
},
)
headers = response.headers
assert "content-crs-json" in headers
projjson_crs = json.loads(headers["content-crs-json"])
assert projjson_crs["type"] == "ProjectedCRS"
assert CRS.from_user_input(projjson_crs).to_epsg() == 32621
25 changes: 24 additions & 1 deletion src/titiler/core/titiler/core/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import abc
import base64
import json
import logging
import os
import warnings
Expand Down Expand Up @@ -79,7 +80,7 @@
Statistics,
StatisticsGeoJSON,
)
from titiler.core.resources.enums import ImageType, MediaType
from titiler.core.resources.enums import ImageType, MediaType, OptionalHeader
from titiler.core.resources.responses import GeoJSONResponse, JSONResponse
from titiler.core.routing import EndpointScope
from titiler.core.telemetry import factory_trace
Expand Down Expand Up @@ -333,6 +334,8 @@ class TilerFactory(BaseFactory):

render_func: Callable[..., tuple[bytes, str]] = render_image

optional_headers: list[OptionalHeader] = field(factory=list)

# Add/Remove some endpoints
add_preview: bool = True
add_part: bool = True
Expand Down Expand Up @@ -940,6 +943,10 @@ def tile(
headers["Content-Bbox"] = ",".join(map(str, image.bounds))
if uri := CRS_to_uri(image.crs):
headers["Content-Crs"] = f"<{uri}>"
if OptionalHeader.projjson_crs in self.optional_headers:
headers["Content-Crs-JSON"] = json.dumps(
image.crs.to_dict(projjson=True)
)

return Response(content, media_type=media_type, headers=headers)

Expand Down Expand Up @@ -1211,6 +1218,10 @@ def preview(
headers["Content-Bbox"] = ",".join(map(str, image.bounds))
if uri := CRS_to_uri(image.crs):
headers["Content-Crs"] = f"<{uri}>"
if OptionalHeader.projjson_crs in self.optional_headers:
headers["Content-Crs-JSON"] = json.dumps(
image.crs.to_dict(projjson=True)
)

return Response(content, media_type=media_type, headers=headers)

Expand Down Expand Up @@ -1283,6 +1294,10 @@ def bbox_image(
headers["Content-Bbox"] = ",".join(map(str, image.bounds))
if uri := CRS_to_uri(image.crs):
headers["Content-Crs"] = f"<{uri}>"
if OptionalHeader.projjson_crs in self.optional_headers:
headers["Content-Crs-JSON"] = json.dumps(
image.crs.to_dict(projjson=True)
)

return Response(content, media_type=media_type, headers=headers)

Expand Down Expand Up @@ -1351,6 +1366,10 @@ def feature_image(
headers["Content-Bbox"] = ",".join(map(str, image.bounds))
if uri := CRS_to_uri(image.crs):
headers["Content-Crs"] = f"<{uri}>"
if OptionalHeader.projjson_crs in self.optional_headers:
headers["Content-Crs-JSON"] = json.dumps(
image.crs.to_dict(projjson=True)
)

return Response(content, media_type=media_type, headers=headers)

Expand Down Expand Up @@ -1437,6 +1456,10 @@ def get_map(
headers["Content-Bbox"] = ",".join(map(str, image.bounds))
if uri := CRS_to_uri(image.crs):
headers["Content-Crs"] = f"<{uri}>"
if OptionalHeader.projjson_crs in self.optional_headers:
headers["Content-Crs-JSON"] = json.dumps(
image.crs.to_dict(projjson=True)
)

return Response(content, media_type=media_type, headers=headers)

Expand Down
1 change: 1 addition & 0 deletions src/titiler/core/titiler/core/resources/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,4 @@ class OptionalHeader(str, Enum):

server_timing = "Server-Timing"
x_assets = "X-Assets"
projjson_crs = "PROJJSON-Crs"
25 changes: 25 additions & 0 deletions src/titiler/mosaic/tests/test_factory.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Test TiTiler mosaic Factory."""

import json
import os
import tempfile
from contextlib import contextmanager
Expand All @@ -16,6 +17,7 @@
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 starlette.testclient import TestClient

Expand Down Expand Up @@ -849,3 +851,26 @@ def test_wmts_extension_mosaic():
assert ["0", "1"] == list(
layer.tilematrixsetlinks["WebMercatorQuad"].tilematrixlimits
)


def test_optional_headers():
"""Test TilerFactory class."""
mosaic = MosaicTilerFactory(
backend=MosaicJSONBackend,
optional_headers=[OptionalHeader.projjson_crs],
add_part=True,
)
app = FastAPI()
app.include_router(mosaic.router)
add_exception_handlers(app, MOSAIC_STATUS_CODES)

with TestClient(app) as client:
with tmpmosaic() as mosaic_file:
response = client.get(
"/bbox/-74,45,-73,46.png",
params={"url": mosaic_file, "dst_crs": "EPSG:3857"},
)
headers = response.headers
assert "content-crs-json" in headers
projjson_crs = json.loads(headers["content-crs-json"])
assert CRS.from_user_input(projjson_crs).to_epsg() == 3857
21 changes: 21 additions & 0 deletions src/titiler/mosaic/titiler/mosaic/factory.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""TiTiler.mosaic Router factories."""

import json
import logging
import os
from collections.abc import Callable
Expand Down Expand Up @@ -681,6 +682,11 @@ def tile(
if uri := CRS_to_uri(image.crs):
headers["Content-Crs"] = f"<{uri}>"

if OptionalHeader.projjson_crs in self.optional_headers:
headers["Content-Crs-JSON"] = json.dumps(
image.crs.to_dict(projjson=True)
)

if (
OptionalHeader.server_timing in self.optional_headers
and image.metadata.get("timings")
Expand Down Expand Up @@ -1100,6 +1106,11 @@ def bbox_image(
if uri := CRS_to_uri(image.crs):
headers["Content-Crs"] = f"<{uri}>"

if OptionalHeader.projjson_crs in self.optional_headers:
headers["Content-Crs-JSON"] = json.dumps(
image.crs.to_dict(projjson=True)
)

if (
OptionalHeader.server_timing in self.optional_headers
and image.metadata.get("timings")
Expand Down Expand Up @@ -1190,6 +1201,11 @@ def feature_image(
if uri := CRS_to_uri(image.crs):
headers["Content-Crs"] = f"<{uri}>"

if OptionalHeader.projjson_crs in self.optional_headers:
headers["Content-Crs-JSON"] = json.dumps(
image.crs.to_dict(projjson=True)
)

if (
OptionalHeader.server_timing in self.optional_headers
and image.metadata.get("timings")
Expand Down Expand Up @@ -1431,6 +1447,11 @@ def get_map(
if uri := CRS_to_uri(image.crs):
headers["Content-Crs"] = f"<{uri}>"

if OptionalHeader.projjson_crs in self.optional_headers:
headers["Content-Crs-JSON"] = json.dumps(
image.crs.to_dict(projjson=True)
)

if (
OptionalHeader.server_timing in self.optional_headers
and image.metadata.get("timings")
Expand Down
Loading