Skip to content

Commit 8ca050a

Browse files
add openapi parameters for tile dependencies in WMTS endpoints (#1349)
* add openapi parameters for tile dependencies in WMTS endpoints * fix * add plans * update mosaic extension * change; reduce calls to crs methods
1 parent 5a4698f commit 8ca050a

6 files changed

Lines changed: 412 additions & 199 deletions

File tree

CHANGES.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
## Unreleased
44

5+
### titiler.extensions
6+
7+
* add: tile's endpoint parameters to the OpenAPI documentation for WMTS extension
8+
* change: internal of the WMTS extension to increase performance
9+
10+
### titiler.mosaic
11+
12+
* add: tile's endpoint parameters to the OpenAPI documentation for WMTS extension
13+
* change: internal of the WMTS extension to increase performance
14+
515
## 2.0.0 (2026-03-16)
616

717
### Misc
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# OpenAPI schema from FastAPI dependencies
2+
3+
## Problem
4+
5+
In the WMTS extension, we construct an endpoint which can accept query-parameter or use `render` configuration to inject them as tile layers. Because the tile's dependencies are not part of the endpoint function signature, so FastAPI won't include them in the OpenAPI schema automatically. This means they won't be documented in the interactive API docs or available for client code generation.
6+
7+
We then need to manually extract the query parameter definitions from a set of dependencies and add them to the OpenAPI schema for the endpoint.
8+
9+
See issue [#1345](https://github.com/developmentseed/titiler/issues/1345)
10+
11+
## Utility function
12+
13+
Add to your utils module:
14+
15+
```python
16+
from fastapi._compat import get_definitions, get_flat_models_from_fields, get_model_name_map
17+
from fastapi.dependencies.models import Dependant
18+
from fastapi.dependencies.utils import get_dependant, get_flat_params
19+
from fastapi.openapi.utils import _get_openapi_operation_parameters
20+
21+
def dependencies_to_openapi_params(
22+
dependencies: list[Callable],
23+
) -> list[dict[str, Any]]:
24+
"""Extract OpenAPI query parameter schemas from a list of FastAPI dependencies."""
25+
all_fields = []
26+
seen: set[str] = set()
27+
for dep in dependencies:
28+
dependant = get_dependant(path="", call=dep)
29+
for field in get_flat_params(dependant):
30+
if field.name not in seen:
31+
seen.add(field.name)
32+
all_fields.append(field)
33+
34+
if not all_fields:
35+
return []
36+
37+
flat_models = get_flat_models_from_fields(all_fields, known_models=set())
38+
model_name_map = get_model_name_map(flat_models)
39+
field_mapping, _ = get_definitions(fields=all_fields, model_name_map=model_name_map)
40+
41+
combined = Dependant(path="")
42+
combined.query_params = all_fields
43+
return _get_openapi_operation_parameters(
44+
dependant=combined,
45+
model_name_map=model_name_map,
46+
field_mapping=field_mapping,
47+
)
48+
```
49+
50+
## Usage in a route
51+
52+
Pass the result to `openapi_extra` in the route decorator:
53+
54+
```python
55+
@router.get(
56+
"/some-endpoint",
57+
openapi_extra={
58+
"parameters": dependencies_to_openapi_params(my_dependencies),
59+
},
60+
)
61+
def my_endpoint(...):
62+
...
63+
```
64+
65+
## Implementation plan
66+
67+
1. **Add utility to `titiler/core/titiler/core/utils.py`**
68+
- Add imports: `get_definitions`, `get_flat_models_from_fields`, `get_model_name_map` from `fastapi._compat`; `Dependant` from `fastapi.dependencies.models`; `get_flat_params` from `fastapi.dependencies.utils`; `_get_openapi_operation_parameters` from `fastapi.openapi.utils`
69+
- Add the `dependencies_to_openapi_params` function (see above)
70+
- Export it from `titiler.core.utils`
71+
72+
2. **Use in the extension route** (e.g. `titiler/extensions/titiler/extensions/wmts.py`)
73+
- Import `dependencies_to_openapi_params` from `titiler.core.utils`
74+
- Before registering the route, build the list of `tile_dependencies`
75+
- Pass `"parameters": dependencies_to_openapi_params(tile_dependencies)` to `openapi_extra` on the `@router.get(...)` decorator
76+
77+
3. **Tests**
78+
- In `titiler/core/tests/test_utils.py`: add `test_dependencies_to_openapi_params` covering empty list, single dep, multiple deps merged, `required` flag, and deduplication
79+
80+
## Notes
81+
82+
- Parameters are deduplicated by name across all dependencies.
83+
- `required`, `schema`, and `description` are derived from the `Query(...)` annotations on each dependency.
84+
- This uses FastAPI internals (`_get_openapi_operation_parameters`, `_compat`) — tested against FastAPI 0.135+.

src/titiler/core/tests/test_utils.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
"""Test utils."""
22

3+
from typing import Annotated
4+
35
import pytest
6+
from fastapi import Query
47

58
from titiler.core.dependencies import AssetsExprParams, BidxParams
69
from titiler.core.resources.enums import MediaType
710
from titiler.core.utils import (
811
accept_media_type,
912
check_query_params,
13+
dependencies_to_openapi_params,
1014
deserialize_query_params,
1115
extract_query_params,
1216
get_dependency_query_params,
@@ -107,6 +111,41 @@ def test_extract_query_params():
107111
assert len(err) == 0
108112

109113

114+
def test_dependencies_to_openapi_params():
115+
"""Test dependencies_to_openapi_params.
116+
117+
This Code was generated with help of Claude 2.0. See plans/openapi-params-from-dependencies.prompt.md.
118+
"""
119+
120+
# empty list → no params
121+
assert dependencies_to_openapi_params([]) == []
122+
123+
# single dependency
124+
params = dependencies_to_openapi_params([BidxParams])
125+
names = [p["name"] for p in params]
126+
assert names == ["bidx"]
127+
assert all(p["in"] == "query" for p in params)
128+
assert params[0]["required"] is False
129+
130+
# multiple dependencies are merged
131+
params = dependencies_to_openapi_params([BidxParams, AssetsExprParams])
132+
names = [p["name"] for p in params]
133+
assert "bidx" in names
134+
assert "assets" in names
135+
assert "expression" in names
136+
137+
# required flag is respected
138+
assets_param = next(p for p in params if p["name"] == "assets")
139+
assert assets_param["required"] is True
140+
141+
# duplicate params across dependencies are deduplicated
142+
def dep_a(scale: Annotated[float | None, Query()] = None): ...
143+
def dep_b(scale: Annotated[float | None, Query()] = None): ...
144+
145+
params = dependencies_to_openapi_params([dep_a, dep_b])
146+
assert len([p for p in params if p["name"] == "scale"]) == 1
147+
148+
110149
def test_check_query_params():
111150
"""Test check_query_params."""
112151
# invalid bidx value

src/titiler/core/titiler/core/utils.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,19 @@
1212
import pyproj
1313
import rasterio
1414
from fastapi import FastAPI
15+
from fastapi._compat import (
16+
get_definitions,
17+
get_flat_models_from_fields,
18+
get_model_name_map,
19+
)
1520
from fastapi.datastructures import QueryParams
16-
from fastapi.dependencies.utils import get_dependant, request_params_to_args
21+
from fastapi.dependencies.models import Dependant
22+
from fastapi.dependencies.utils import (
23+
get_dependant,
24+
get_flat_params,
25+
request_params_to_args,
26+
)
27+
from fastapi.openapi.utils import _get_openapi_operation_parameters
1728
from geojson_pydantic.geometries import MultiPolygon, Polygon
1829
from morecantile import TileMatrixSet
1930
from rasterio.dtypes import dtype_ranges
@@ -250,6 +261,38 @@ def check_query_params(
250261
return True
251262

252263

264+
def dependencies_to_openapi_params(
265+
dependencies: list[Callable],
266+
) -> list[dict[str, Any]]:
267+
"""Extract OpenAPI query parameter schemas from a list of FastAPI dependencies.
268+
269+
This Code was generated with help of Claude 2.0. See plans/openapi-params-from-dependencies.prompt.md.
270+
"""
271+
all_fields = []
272+
seen: set[str] = set()
273+
for dep in dependencies:
274+
dependant = get_dependant(path="", call=dep)
275+
for field in get_flat_params(dependant):
276+
if field.name not in seen:
277+
seen.add(field.name)
278+
all_fields.append(field)
279+
280+
if not all_fields:
281+
return []
282+
283+
flat_models = get_flat_models_from_fields(all_fields, known_models=set())
284+
model_name_map = get_model_name_map(flat_models)
285+
field_mapping, _ = get_definitions(fields=all_fields, model_name_map=model_name_map)
286+
287+
combined = Dependant(path="")
288+
combined.query_params = all_fields
289+
return _get_openapi_operation_parameters(
290+
dependant=combined,
291+
model_name_map=model_name_map,
292+
field_mapping=field_mapping,
293+
)
294+
295+
253296
def accept_media_type(accept: str, mediatypes: list[MediaType]) -> MediaType | None:
254297
"""Return MediaType based on accept header and available mediatype.
255298

0 commit comments

Comments
 (0)