Skip to content

Commit 303cb4f

Browse files
Merge pull request #255 from sparkgeo/254-input-validation-changes
254 input validation changes
2 parents 89e101f + 417a843 commit 303cb4f

9 files changed

Lines changed: 278 additions & 44 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ ENV/
100100
# mypy
101101
.mypy_cache/
102102

103+
# IDE
104+
.vscode
105+
103106
log-config.yml
104107

105108
.pgdata/

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
* disable tests for pre-0.9 pgstac versions
6+
* address several instances of validation failures returning 500 instead of 4xx HTTP responses
67

78
## 2.0.0 (2026-01-13)
89

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,13 @@ classifiers = [
2727
"Topic :: Scientific/Engineering :: GIS",
2828
]
2929
dependencies = [
30-
"titiler.core>=1.1,<1.2",
31-
"titiler.mosaic>=1.1,<1.2",
32-
"titiler.extensions>=1.1,<1.2",
30+
"titiler.core>=1.2,<1.3",
31+
"titiler.mosaic>=1.2,<1.3",
32+
"titiler.extensions>=1.2,<1.3",
3333
"cql2>=0.3.6",
3434
"pydantic>=2.4,<3.0",
3535
"pydantic-settings~=2.0",
36+
"stac-pydantic>=3.5.0,<4",
3637
]
3738
dynamic = ["version"]
3839

tests/test_collections.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Test titiler.pgstac Mosaic endpoints."""
22

3+
import datetime
34
import io
45
import json
56
from unittest.mock import patch
@@ -692,3 +693,147 @@ def test_collections_cql_filter(filter_expr, filter_lang, app):
692693
assert len(resp) == 1
693694
assert list(resp[0]) == ["id", "bbox", "assets", "collection"]
694695
assert resp[0]["id"] == "20200307aC0853000w361030"
696+
697+
698+
def test_datetime_validation(app) -> None:
699+
"""Ensure datetime parameter validation works as expected."""
700+
valid_response = app.get(
701+
f"/collections/{collection_id}/tiles",
702+
params={
703+
"datetime": datetime.datetime.now(tz=datetime.UTC).isoformat(),
704+
},
705+
)
706+
assert valid_response.status_code == 200
707+
invalid_response = app.get(
708+
f"/collections/{collection_id}/tiles",
709+
params={
710+
"datetime": "this is not a valid datetime string",
711+
},
712+
)
713+
assert invalid_response.status_code == 422
714+
715+
716+
def test_query_validation(app) -> None:
717+
"""Ensure query parameter validation works as expected."""
718+
valid_response = app.get(
719+
f"/collections/{collection_id}/tiles",
720+
params={
721+
"query": json.dumps({"eo:cloud_cover": {"gte": 95}}),
722+
},
723+
)
724+
assert valid_response.status_code == 200
725+
invalid_response = app.get(
726+
f"/collections/{collection_id}/tiles",
727+
params={
728+
"query": "this is not a valid query string",
729+
},
730+
)
731+
assert invalid_response.status_code == 422
732+
733+
734+
def test_query_json_content(app) -> None:
735+
"""Ensure valid JSON with invalid query content is validated correctly."""
736+
invalid_response = app.get(
737+
f"/collections/{collection_id}/tiles",
738+
params={
739+
"query": json.dumps({"this": "is not a valid query"}),
740+
},
741+
)
742+
assert invalid_response.status_code == 422
743+
744+
745+
def test_filter_json_validation(app) -> None:
746+
"""Ensure JSON filter parameter validation works as expected."""
747+
valid_response = app.get(
748+
f"/collections/{collection_id}/tiles",
749+
params={
750+
"filter": json.dumps(
751+
{
752+
"op": "lte",
753+
"args": [
754+
{
755+
"property": "gsd",
756+
},
757+
10,
758+
],
759+
}
760+
),
761+
"filter-lang": "cql2-json",
762+
},
763+
)
764+
assert valid_response.status_code == 200
765+
invalid_content_response = app.get(
766+
f"/collections/{collection_id}/tiles",
767+
params={
768+
"filter": json.dumps({"this": "is invalid content"}),
769+
},
770+
)
771+
assert invalid_content_response.status_code == 422
772+
invalid_json_response = app.get(
773+
f"/collections/{collection_id}/tiles",
774+
params={
775+
"filter": "this is not a valid JSON string",
776+
"filter-lang": "cql2-json",
777+
},
778+
)
779+
assert invalid_json_response.status_code == 422
780+
781+
782+
def test_filter_text_validation(app) -> None:
783+
"""Ensure text filter parameter validation works as expected."""
784+
valid_response = app.get(
785+
f"/collections/{collection_id}/tiles",
786+
params={
787+
"filter": "id=irrelevant-value",
788+
"filter-lang": "cql2-text",
789+
},
790+
)
791+
assert valid_response.status_code == 200
792+
invalid_response = app.get(
793+
f"/collections/{collection_id}/tiles",
794+
params={
795+
"filter": "this is not a valid filter string",
796+
"filter-lang": "cql2-text",
797+
},
798+
)
799+
assert invalid_response.status_code == 422
800+
801+
802+
def test_bbox_validation(app) -> None:
803+
"""Ensure bbox parameter validation works as expected."""
804+
valid_response_3d = app.get(
805+
f"/collections/{collection_id}/tiles",
806+
params={
807+
"bbox": "-180,-90,-1,180,90,1",
808+
},
809+
)
810+
assert valid_response_3d.status_code == 200
811+
812+
valid_response_2d = app.get(
813+
f"/collections/{collection_id}/tiles",
814+
params={
815+
"bbox": "-180,-90,180,90",
816+
},
817+
)
818+
assert valid_response_2d.status_code == 200
819+
820+
invalid_format_1_response = app.get(
821+
f"/collections/{collection_id}/tiles",
822+
params={
823+
"bbox": "invalid bbox string",
824+
},
825+
)
826+
assert invalid_format_1_response.status_code == 422
827+
828+
invalid_format_2_response = app.get(
829+
f"/collections/{collection_id}/tiles",
830+
params={
831+
"bbox": "-180,-90,180",
832+
},
833+
)
834+
assert invalid_format_2_response.status_code == 422
835+
836+
invalid_values_response = app.get(
837+
f"/collections/{collection_id}/tiles", params={"bbox": "180,-90,-180,90"}
838+
)
839+
assert invalid_values_response.status_code == 422

titiler/pgstac/dependencies.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,18 @@
1717
from psycopg import errors as pgErrors
1818
from psycopg.rows import class_row, dict_row
1919
from psycopg_pool import ConnectionPool
20+
from pydantic import BeforeValidator
21+
from stac_pydantic.shared import validate_datetime
2022
from starlette.requests import Request
2123
from typing_extensions import Annotated
2224

2325
from titiler.core.dependencies import DefaultDependency
26+
from titiler.core.validation import validate_json
2427
from titiler.pgstac import model
2528
from titiler.pgstac.errors import MosaicNotFoundError, ReadOnlyPgSTACError
2629
from titiler.pgstac.settings import CacheSettings, RetrySettings
2730
from titiler.pgstac.utils import retry
31+
from titiler.pgstac.validation import parse_and_validate_bbox, validate_filter
2832

2933
cache_config = CacheSettings()
3034
retry_config = RetrySettings()
@@ -215,6 +219,7 @@ def CollectionIdParams(
215219
] = None,
216220
bbox: Annotated[
217221
str | None,
222+
BeforeValidator(parse_and_validate_bbox),
218223
Query(
219224
description="Filters items intersecting this bounding box",
220225
openapi_examples={
@@ -225,6 +230,7 @@ def CollectionIdParams(
225230
] = None,
226231
datetime: Annotated[
227232
str | None,
233+
BeforeValidator(validate_datetime),
228234
Query(
229235
description="""Filters items that have a temporal property that intersects this value.\n
230236
Either a date-time or an interval, open or closed. Date and time expressions adhere to RFC 3339. Open intervals are expressed using double-dots.""",
@@ -242,6 +248,7 @@ def CollectionIdParams(
242248
# Extensions
243249
query: Annotated[
244250
str | None,
251+
BeforeValidator(validate_json),
245252
Query(
246253
description="Allows additional filtering based on the properties of Item objects",
247254
openapi_examples={
@@ -285,6 +292,7 @@ def CollectionIdParams(
285292
] = "cql2-text",
286293
) -> str:
287294
"""Collection endpoints Parameters"""
295+
validate_filter(filter_expr=filter_expr, filter_lang=filter_lang)
288296
return get_collection_id(
289297
request.app.state.dbpool,
290298
collection_id=collection_id,

titiler/pgstac/main.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66

77
import jinja2
88
import rasterio
9-
from fastapi import FastAPI, Path, Query
9+
from fastapi import FastAPI, Path, Query, status
1010
from psycopg import OperationalError
1111
from psycopg.rows import dict_row
1212
from psycopg_pool import PoolTimeout
13+
from pydantic import ValidationError
1314
from starlette.middleware.cors import CORSMiddleware
1415
from starlette.requests import Request
1516
from starlette.templating import Jinja2Templates
@@ -103,7 +104,12 @@ async def lifespan(app: FastAPI):
103104
# Fix OpenAPI response header for OGC Common compatibility
104105
update_openapi(app)
105106

106-
ERRORS = {**DEFAULT_STATUS_CODES, **MOSAIC_STATUS_CODES, **PGSTAC_STATUS_CODES}
107+
ERRORS = {
108+
**DEFAULT_STATUS_CODES,
109+
**MOSAIC_STATUS_CODES,
110+
**PGSTAC_STATUS_CODES,
111+
ValidationError: status.HTTP_422_UNPROCESSABLE_CONTENT,
112+
}
107113
add_exception_handlers(app, ERRORS)
108114

109115

titiler/pgstac/model.py

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from typing_extensions import Annotated
1717

1818
from titiler.core.resources.enums import MediaType
19+
from titiler.pgstac.validation import validate_bbox as validate_bbox_common
1920

2021
# ref: https://github.com/stac-api-extensions/query
2122
# TODO: add "startsWith", "endsWith", "contains", "in"
@@ -204,34 +205,9 @@ def validate_spatial(cls, v: Geometry | None, info: ValidationInfo):
204205
return v
205206

206207
@field_validator("bbox")
207-
def validate_bbox(cls, v: BBox):
208+
def validate_bbox(cls, value: BBox | None):
208209
"""Validate BBOX."""
209-
if v:
210-
# Validate order
211-
if len(v) == 4:
212-
xmin, ymin, xmax, ymax = v
213-
else:
214-
xmin, ymin, min_elev, xmax, ymax, max_elev = v
215-
if max_elev < min_elev:
216-
raise ValueError(
217-
"Maximum elevation must greater than minimum elevation"
218-
)
219-
220-
if xmax < xmin:
221-
raise ValueError(
222-
"Maximum longitude must be greater than minimum longitude"
223-
)
224-
225-
if ymax < ymin:
226-
raise ValueError(
227-
"Maximum longitude must be greater than minimum longitude"
228-
)
229-
230-
# Validate against WGS84
231-
if xmin < -180 or ymin < -90 or xmax > 180 or ymax > 90:
232-
raise ValueError("Bounding box must be within (-180, -90, 180, 90)")
233-
234-
return v
210+
return validate_bbox_common(value)
235211

236212

237213
class RegisterMosaic(PgSTACSearch):

titiler/pgstac/validation.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""
2+
Validation functions for caller-provided data.
3+
"""
4+
5+
from typing import Literal, cast
6+
7+
from cql2 import Expr
8+
from geojson_pydantic.types import BBox
9+
from pydantic import ValidationError
10+
11+
from titiler.core.validation import validate_json
12+
13+
14+
def validate_filter(
15+
filter_expr: str | None, filter_lang: Literal["cql2-text", "cql2-json"]
16+
) -> None:
17+
"""
18+
Verify that a filter string can be parsed, parsing is determined by the language used.
19+
:param filter_expr: Caller-provided filter value.
20+
:type filter_expr: str | None
21+
:param filter_lang: Caller-provided language value.
22+
:type filter_lang: str
23+
"""
24+
if filter_expr is None:
25+
return
26+
if filter_lang == "cql2-json":
27+
try:
28+
validate_json(json_str=filter_expr)
29+
except ValueError as e:
30+
raise ValidationError(str(e), []) from e
31+
elif filter_lang == "cql2-text":
32+
try:
33+
Expr(filter_expr).validate()
34+
except Exception as e:
35+
raise ValidationError(str(e), []) from e
36+
37+
38+
def validate_bbox(v: BBox | None):
39+
"""Validate BBOX values."""
40+
if v:
41+
# Validate order
42+
if len(v) == 4:
43+
xmin, ymin, xmax, ymax = v
44+
elif len(v) == 6:
45+
xmin, ymin, min_elev, xmax, ymax, max_elev = v
46+
if max_elev < min_elev:
47+
raise ValueError(
48+
"Maximum elevation must greater than minimum elevation"
49+
)
50+
else:
51+
raise ValueError("Bounding box must have 4 or 6 numbers")
52+
53+
if xmax < xmin:
54+
raise ValueError("Maximum longitude must be greater than minimum longitude")
55+
56+
if ymax < ymin:
57+
raise ValueError("Maximum longitude must be greater than minimum longitude")
58+
59+
# Validate against WGS84
60+
if xmin < -180 or ymin < -90 or xmax > 180 or ymax > 90:
61+
raise ValueError("Bounding box must be within (-180, -90, 180, 90)")
62+
63+
return v
64+
65+
66+
def parse_and_validate_bbox(value: str | None):
67+
"""Validate BBOX format and values."""
68+
if value is None:
69+
return None
70+
try:
71+
parsed_value = cast(BBox, [float(x) for x in value.split(",")])
72+
except ValueError as e:
73+
raise ValueError(
74+
"Bounding box must be a comma-separated list of numbers"
75+
) from e
76+
77+
validate_bbox(parsed_value)
78+
return value

0 commit comments

Comments
 (0)