Conversation
There was a problem hiding this comment.
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
tilespackage that exposes a TiTilertiles_routerbased 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.
There was a problem hiding this comment.
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.tilespackage with a TiTiler xarrayTilerFactoryrouter. - Mount the new TiTiler router in the FastAPI app under
/titiler. - Upgrade
titiler-coreand addtitiler-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. |
| from titiler.xarray.factory import TilerFactory | ||
| from titiler.xarray.io import Reader | ||
| from titiler.xarray.extensions import VariablesExtension | ||
|
|
||
| router = TilerFactory( | ||
| reader=Reader, |
There was a problem hiding this comment.
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.
| 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, |
| 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"]) |
There was a problem hiding this comment.
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.
This PR adds new "/titiler/..." endpoints allowing tiles to be generated from a zarr folder (or an url).
Example tile urls:
http://127.0.0.1:8000/titiler/tiles/WebMercatorQuad/12/1913/1949?url=data/downloads/worldpop_population_yearly.zarr&variable=pop_total&sel=time=2020-01-01&colormap_name=ylorbr
Returns:

http://127.0.0.1:8000/titiler/tiles/WebMercatorQuad/9/239/244?url=data/downloads/worldpop_population_yearly.zarr&variable=pop_total&sel=time=2026-01-01&colormap=[[[0,5],[254,229,217]],[[5,10],[252,187,161]],[[10,15],[252,146,114]],[[15,20],[251,106,74]],[[20,25],[222,45,38]],[[25,10000],[165,15,21]]]
(GEE version to the right)