Skip to content

Add TiTiler endpoints for zarr datasets#25

Draft
turban wants to merge 10 commits intomainfrom
titiler-zarr
Draft

Add TiTiler endpoints for zarr datasets#25
turban wants to merge 10 commits intomainfrom
titiler-zarr

Conversation

@turban turban requested review from abyot and karimbahgat March 16, 2026 08:05
@turban turban changed the title Add TiTiler endpoint for zarr datasets Add TiTiler endpoints for zarr datasets Mar 16, 2026
@turban turban requested a review from Copilot March 16, 2026 08:12
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds TiTiler xarray-backed tile endpoints to eo-api, wiring a new router into the FastAPI app and introducing early-boot PROJ data configuration to avoid CRS/proj.db issues when importing raster tooling.

Changes:

  • Add titiler-xarray (and its transitive deps) to the project dependencies/lockfile.
  • Introduce a tiles package that exposes a TiTiler tiles_router based on xarray/zarr.
  • Configure PROJ data env vars during startup to prefer the active Python environment’s PROJ data.

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
pyproject.toml Adds titiler-xarray as an application dependency.
uv.lock Locks titiler-xarray and new transitive deps (e.g., obstore).
src/eo_api/tiles/titiler.py Defines a TiTiler xarray TilerFactory router for zarr/xarray tiling.
src/eo_api/tiles/__init__.py Exports tiles_router for easy import/registration.
src/eo_api/startup.py Adds PROJ env var configuration during early-boot side effects.
src/eo_api/main.py Mounts the new tiles router under /zarr.

You can also share your feedback on Copilot code review. Take the survey.

Comment thread src/eo_api/tiles/titiler.py
Comment thread src/eo_api/tiles/titiler.py Outdated
Comment thread src/eo_api/startup.py Outdated
Comment thread src/eo_api/startup.py Outdated
Comment thread pyproject.toml Outdated
Comment thread src/eo_api/main.py Outdated
@turban turban marked this pull request as draft April 7, 2026 08:31
@turban turban requested a review from Copilot April 7, 2026 13:27
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces TiTiler (xarray-backed) endpoints to serve map tiles from Zarr datasets, and updates TiTiler-related dependencies to support that functionality.

Changes:

  • Add a new eo_api.tiles package with a TiTiler xarray TilerFactory router.
  • Mount the new TiTiler router in the FastAPI app under /titiler.
  • Upgrade titiler-core and add titiler-xarray (plus lockfile updates).

Reviewed changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
pyproject.toml Bumps titiler-core and adds titiler-xarray dependency.
uv.lock Locks new/updated TiTiler and related dependency versions (incl. rio-tiler bump).
src/eo_api/tiles/titiler.py Defines the xarray-backed TiTiler router via TilerFactory + Reader.
src/eo_api/tiles/__init__.py Exposes TiTiler routes module for app wiring.
src/eo_api/main.py Includes the TiTiler router under the /titiler prefix.

Comment on lines +3 to +8
from titiler.xarray.factory import TilerFactory
from titiler.xarray.io import Reader
from titiler.xarray.extensions import VariablesExtension

router = TilerFactory(
reader=Reader,
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mounting TiTiler with titiler.xarray.io.Reader and exposing the upstream url/path query parameter (as shown in the PR examples) can allow arbitrary remote fetches (SSRF) and/or local file reads depending on the Reader’s supported schemes. Consider wrapping/replacing the Reader with one that enforces an allowlist (e.g., only local dataset roots or specific URL schemes/hosts), and explicitly rejecting file:// and private-network destinations if HTTP(S) is allowed.

Suggested change
from titiler.xarray.factory import TilerFactory
from titiler.xarray.io import Reader
from titiler.xarray.extensions import VariablesExtension
router = TilerFactory(
reader=Reader,
import ipaddress
import os
import socket
from pathlib import Path
from urllib.parse import urlparse
from fastapi import HTTPException
from titiler.xarray.factory import TilerFactory
from titiler.xarray.io import Reader
from titiler.xarray.extensions import VariablesExtension
_ALLOWED_DATASET_ROOTS_ENV = "EO_API_TITILER_ALLOWED_DATASET_ROOTS"
_ALLOWED_REMOTE_HOSTS_ENV = "EO_API_TITILER_ALLOWED_REMOTE_HOSTS"
_ALLOWED_REMOTE_SCHEMES_ENV = "EO_API_TITILER_ALLOWED_REMOTE_SCHEMES"
def _get_env_list(name: str) -> list[str]:
value = os.getenv(name, "")
return [item.strip() for item in value.split(",") if item.strip()]
def _path_is_within(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def _hostname_targets_private_network(hostname: str) -> bool:
try:
parsed_ip = ipaddress.ip_address(hostname)
return (
parsed_ip.is_private
or parsed_ip.is_loopback
or parsed_ip.is_link_local
or parsed_ip.is_multicast
or parsed_ip.is_reserved
or parsed_ip.is_unspecified
)
except ValueError:
pass
try:
address_info = socket.getaddrinfo(hostname, None)
except socket.gaierror:
return True
for entry in address_info:
candidate_ip = ipaddress.ip_address(entry[4][0])
if (
candidate_ip.is_private
or candidate_ip.is_loopback
or candidate_ip.is_link_local
or candidate_ip.is_multicast
or candidate_ip.is_reserved
or candidate_ip.is_unspecified
):
return True
return False
def _validate_source(source: str) -> None:
parsed = urlparse(source)
if parsed.scheme:
scheme = parsed.scheme.lower()
if scheme == "file":
raise HTTPException(
status_code=400,
detail="file:// sources are not allowed for tile requests.",
)
if scheme not in {"http", "https"}:
raise HTTPException(
status_code=400,
detail=f"Unsupported source scheme '{scheme}'.",
)
allowed_schemes = {item.lower() for item in _get_env_list(_ALLOWED_REMOTE_SCHEMES_ENV)}
if not allowed_schemes or scheme not in allowed_schemes:
raise HTTPException(
status_code=400,
detail="Remote sources are not allowed for this deployment.",
)
hostname = parsed.hostname
if not hostname:
raise HTTPException(
status_code=400,
detail="Remote source URL must include a hostname.",
)
allowed_hosts = {item.lower() for item in _get_env_list(_ALLOWED_REMOTE_HOSTS_ENV)}
if not allowed_hosts or hostname.lower() not in allowed_hosts:
raise HTTPException(
status_code=400,
detail="Remote source host is not allowed.",
)
if _hostname_targets_private_network(hostname):
raise HTTPException(
status_code=400,
detail="Remote source resolves to a private or otherwise disallowed network address.",
)
return
allowed_roots = [
Path(item).expanduser().resolve(strict=False)
for item in _get_env_list(_ALLOWED_DATASET_ROOTS_ENV)
]
if not allowed_roots:
raise HTTPException(
status_code=400,
detail="Local sources are not allowed for this deployment.",
)
resolved_source = Path(source).expanduser().resolve(strict=False)
if not any(_path_is_within(resolved_source, root) for root in allowed_roots):
raise HTTPException(
status_code=400,
detail="Local source path is outside the configured allowed dataset roots.",
)
class RestrictedReader(Reader):
def __init__(self, *args, **kwargs) -> None:
source = kwargs.get("src_path") or kwargs.get("url") or (args[0] if args else None)
if not isinstance(source, str) or not source:
raise HTTPException(
status_code=400,
detail="A valid source path or URL is required.",
)
_validate_source(source)
super().__init__(*args, **kwargs)
router = TilerFactory(
reader=RestrictedReader,

Copilot uses AI. Check for mistakes.
Comment thread src/eo_api/main.py
Comment on lines 28 to +31
app.include_router(ingestion_routes.ingestions_router, prefix="/ingestions", tags=["Ingestions"])
app.include_router(ingestion_routes.zarr_router, prefix="/zarr", tags=["Zarr"])
app.include_router(ingestion_routes.sync_router, prefix="/sync", tags=["Sync"])
app.include_router(titiler_routes.router, prefix='/titiler', tags=["TiTiler"])
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a new public API surface under /titiler but there are no tests covering router registration or basic request/response behavior (including expected failures for invalid/malicious url inputs). Add FastAPI TestClient coverage for at least one happy-path tile request (can be skipped/marked if test data isn’t available) and one validation/rejection case.

Copilot generated this review using guidance from repository custom instructions.
Comment thread src/eo_api/main.py
Comment thread src/eo_api/main.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants