Skip to content

Commit 6c3bf25

Browse files
Merge pull request #235 from stac-utils/feature/add-filter-collections-params
add filter extension to collections endpoints
2 parents b74079e + 236d4bf commit 6c3bf25

File tree

7 files changed

+262
-187
lines changed

7 files changed

+262
-187
lines changed

CHANGES.md

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

33
## Unreleased
44

5+
* remove `cql-text` support for PgSTACSearch `filter`
6+
* add `filter` and `filter-lang` for CollectionIdParams dependency
7+
58
## 1.9.0 (2025-09-23)
69

710
* update titiler requirement to `>=0.24,<0.25`

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ classifiers = [
2929
dependencies = [
3030
"titiler.core>=0.24,<0.25",
3131
"titiler.mosaic>=0.24,<0.25",
32+
"cql2>=0.3.6",
3233
"pydantic>=2.4,<3.0",
3334
"pydantic-settings~=2.0",
3435
]

tests/fixtures/noaa-eri-nashville2020.json

Lines changed: 163 additions & 163 deletions
Large diffs are not rendered by default.

tests/test_collections.py

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

33
import io
4+
import json
45
from unittest.mock import patch
56

7+
import pytest
68
import rasterio
79
from rasterio.crs import CRS
810

@@ -655,3 +657,26 @@ def test_collections_additional_parameters(app):
655657
{"field": "datetime", "direction": "asc"},
656658
{"field": "cloud", "direction": "asc"},
657659
]
660+
661+
662+
@pytest.mark.parametrize(
663+
"filter_expr,filter_lang",
664+
[
665+
(json.dumps({"op": "=", "args": [{"property": "value"}, "1"]}), "cql2-json"),
666+
("(value = '1')", "cql2-text"),
667+
],
668+
)
669+
def test_collections_cql_filter(filter_expr, filter_lang, app):
670+
"""Get assets for a specific collection and filter."""
671+
response = app.get(
672+
f"/collections/{collection_id}/point/-85.5,36.1624/assets",
673+
params={
674+
"filter": filter_expr,
675+
"filter-lanq": filter_lang,
676+
},
677+
)
678+
assert response.status_code == 200
679+
resp = response.json()
680+
assert len(resp) == 1
681+
assert list(resp[0]) == ["id", "bbox", "assets", "collection"]
682+
assert resp[0]["id"] == "20200307aC0853000w361030"

tests/test_searches.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
@pytest.fixture
1515
def search_no_bbox(app):
1616
"""Search Query without BBOX."""
17-
query = {"collections": ["noaa-emergency-response"], "filter-lang": "cql-json"}
17+
query = {"collections": ["noaa-emergency-response"]}
1818
response = app.post("/searches/register", json=query)
1919
assert response.status_code == 200
2020
resp = response.json()
@@ -34,7 +34,6 @@ def search_bbox(app):
3434
query = {
3535
"collections": ["noaa-emergency-response"],
3636
"bbox": [-85.535, 36.137, -85.465, 36.179],
37-
"filter-lang": "cql-json",
3837
}
3938
response = app.post("/searches/register", json=query)
4039
assert response.status_code == 200
@@ -60,7 +59,7 @@ def test_info(app, search_no_bbox):
6059
search = resp["search"]
6160
assert search["search"] == {
6261
"collections": ["noaa-emergency-response"],
63-
"filter-lang": "cql-json",
62+
"filter-lang": "cql2-json",
6463
}
6564
assert search["metadata"] == {"type": "mosaic"}
6665

@@ -1093,7 +1092,7 @@ def test_search_ids_parameter(app):
10931092
query = {
10941093
"collections": ["noaa-emergency-response"],
10951094
"ids": ["20200307aC0853130w361030"],
1096-
"filter-lang": "cql-json",
1095+
"filter-lang": "cql2-json",
10971096
}
10981097
response = app.post("/searches/register", json=query)
10991098
assert response.status_code == 200

titiler/pgstac/dependencies.py

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55
import warnings
66
from dataclasses import dataclass, field
77
from threading import Lock
8-
from typing import List, Optional, Tuple
8+
from typing import Any, Dict, List, Literal, Optional, Tuple
99
from urllib.parse import unquote_plus
1010

1111
import morecantile
1212
import pystac
1313
from cachetools import TTLCache, cached
1414
from cachetools.keys import hashkey
1515
from cogeo_mosaic.errors import MosaicNotFoundError
16+
from cql2 import Expr
1617
from fastapi import HTTPException, Path, Query
1718
from psycopg import errors as pgErrors
1819
from psycopg.rows import class_row, dict_row
@@ -42,13 +43,23 @@ def SearchIdParams(
4243

4344
@cached( # type: ignore
4445
TTLCache(maxsize=cache_config.maxsize, ttl=cache_config.ttl),
45-
key=lambda pool, collection_id, ids, bbox, datetime, query, sortby: hashkey(
46+
key=lambda pool,
47+
collection_id,
48+
ids,
49+
bbox,
50+
datetime,
51+
query,
52+
sortby,
53+
filter_expr,
54+
filter_lang: hashkey(
4655
collection_id,
4756
ids,
4857
bbox,
4958
datetime,
5059
query,
5160
sortby,
61+
filter_expr,
62+
filter_lang,
5263
),
5364
lock=Lock(),
5465
)
@@ -66,10 +77,23 @@ def get_collection_id( # noqa: C901
6677
ids: Optional[str] = None,
6778
bbox: Optional[str] = None,
6879
datetime: Optional[str] = None,
80+
# Extensions
6981
query: Optional[str] = None,
7082
sortby: Optional[str] = None,
83+
filter_expr: Optional[str] = None,
84+
filter_lang: Literal["cql2-text", "cql2-json"] = "cql2-json",
7185
) -> str: # noqa: C901
7286
"""Get Search Id for a Collection."""
87+
search_params: Dict[str, Any] = {
88+
"collections": [collection_id],
89+
"datetime": datetime,
90+
}
91+
if ids:
92+
search_params["ids"] = ids.split(",")
93+
94+
if bbox:
95+
search_params["bbox"] = list(map(float, bbox.split(",")))
96+
7397
sort_param: List[model.SortExtension] = []
7498
if sortby:
7599
for sort in sortby.split(","):
@@ -80,16 +104,21 @@ def get_collection_id( # noqa: C901
80104
direction="desc" if sortparts.group(1) == "-" else "asc",
81105
)
82106
)
107+
if sort_param:
108+
search_params["sortby"] = sort_param
83109

84-
search = model.PgSTACSearch(
85-
collections=[collection_id],
86-
ids=ids.split(",") if ids else None,
87-
bbox=list(map(float, bbox.split(","))) if bbox else None,
88-
datetime=datetime,
89-
query=json.loads(unquote_plus(query)) if query else None,
90-
sortby=sort_param or None,
91-
)
110+
if filter_expr:
111+
if filter_lang == "cql2-text":
112+
search_params["filter"] = Expr(filter_expr).to_json()
113+
search_params["filter-lang"] = "cql2-json"
114+
else:
115+
search_params["filter"] = json.loads(filter_expr)
116+
search_params["filter-lang"] = filter_lang
92117

118+
if query:
119+
search_params["query"] = json.loads(unquote_plus(query))
120+
121+
search = model.PgSTACSearch.model_validate(search_params)
93122
with pool.connection() as conn:
94123
with conn.cursor(row_factory=dict_row) as cursor:
95124
cursor.execute(
@@ -210,6 +239,7 @@ def CollectionIdParams(
210239
},
211240
),
212241
] = None,
242+
# Extensions
213243
query: Annotated[
214244
Optional[str],
215245
Query(
@@ -231,6 +261,28 @@ def CollectionIdParams(
231261
},
232262
),
233263
] = None,
264+
filter_expr: Annotated[
265+
Optional[str],
266+
Query(
267+
alias="filter",
268+
description="""A CQL2 filter expression for filtering items.\n
269+
Supports `CQL2-JSON` as defined in https://docs.ogc.org/is/21-065r2/21-065r2.htmln
270+
Remember to URL encode the CQL2-JSON if using GET""",
271+
openapi_examples={
272+
"user-provided": {"value": None},
273+
"landsat8-item": {
274+
"value": "id='LC08_L1TP_060247_20180905_20180912_01_T1_L1TP' AND collection='landsat8_l1tp'" # noqa: E501
275+
},
276+
},
277+
),
278+
] = None,
279+
filter_lang: Annotated[
280+
Literal["cql2-text", "cql2-json"],
281+
Query(
282+
alias="filter-lang",
283+
description="The coordinate reference system (CRS) used by spatial literals in the 'filter' value. Default is `http://www.opengis.net/def/crs/OGC/1.3/CRS84`",
284+
),
285+
] = "cql2-text",
234286
) -> str:
235287
"""Collection endpoints Parameters"""
236288
return get_collection_id(
@@ -241,6 +293,8 @@ def CollectionIdParams(
241293
datetime=datetime,
242294
query=query,
243295
sortby=sortby,
296+
filter_expr=filter_expr,
297+
filter_lang=filter_lang,
244298
)
245299

246300

titiler/pgstac/model.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,6 @@
2121
# TODO: add "startsWith", "endsWith", "contains", "in"
2222
Operator = Literal["eq", "neq", "lt", "lte", "gt", "gte"]
2323

24-
# ref: https://github.com/radiantearth/stac-api-spec/tree/master/fragments/filter#get-query-parameters-and-post-json-fields
25-
FilterLang = Literal["cql-json", "cql-text", "cql2-json"]
26-
2724

2825
class Metadata(BaseModel):
2926
"""Metadata Model."""
@@ -135,8 +132,9 @@ class PgSTACSearch(BaseModel, extra="allow"):
135132
ids: Optional[List[str]] = None
136133
bbox: Optional[BBox] = None
137134
intersects: Optional[Geometry] = None
138-
query: Optional[Dict[str, Dict[Operator, Any]]] = None
139135
datetime: Optional[str] = None
136+
# Extensions
137+
query: Optional[Dict[str, Dict[Operator, Any]]] = None
140138
sortby: Optional[List[SortExtension]] = Field(
141139
default=None,
142140
description="An array of property (field) names, and direction in form of '{'field': '<property_name>', 'direction':'<direction>'}'", # noqa: E501
@@ -182,12 +180,7 @@ class PgSTACSearch(BaseModel, extra="allow"):
182180
],
183181
},
184182
)
185-
filter_crs: Optional[str] = Field(
186-
default=None,
187-
alias="filter-crs",
188-
description="The coordinate reference system (CRS) used by spatial literals in the 'filter' value. Default is `http://www.opengis.net/def/crs/OGC/1.3/CRS84`",
189-
)
190-
filter_lang: Optional[FilterLang] = Field(
183+
filter_lang: Optional[Literal["cql2-json"]] = Field(
191184
default="cql2-json",
192185
alias="filter-lang",
193186
description="The CQL filter encoding that the 'filter' value uses.",

0 commit comments

Comments
 (0)