Skip to content

Commit e10ee6b

Browse files
authored
Merge pull request #163 from stac-utils/get-filter-extension
GET filter extension requests
2 parents 5d71684 + 89fb57d commit e10ee6b

File tree

4 files changed

+139
-25
lines changed

4 files changed

+139
-25
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1111

1212
- Collection-level Assets to the CollectionSerializer [#148](https://github.com/stac-utils/stac-fastapi-elasticsearch/issues/148)
1313
- Examples folder with example docker setup for running sfes from pip [#147](https://github.com/stac-utils/stac-fastapi-elasticsearch/pull/147)
14+
- GET /search filter extension queries [#163](https://github.com/stac-utils/stac-fastapi-elasticsearch/pull/163)
1415
- Added support for GET /search intersection queries [#158](https://github.com/stac-utils/stac-fastapi-elasticsearch/issues/158)
1516

1617
### Changed

stac_fastapi/elasticsearch/setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"elasticsearch-dsl==7.4.1",
1818
"pystac[validation]",
1919
"uvicorn",
20+
"orjson",
2021
"overrides",
2122
"starlette",
2223
"geojson-pydantic",

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/core.py

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
"""Item crud client."""
2-
import json
32
import logging
3+
import re
44
from datetime import datetime as datetime_type
55
from datetime import timezone
66
from typing import Any, Dict, List, Optional, Set, Type, Union
77
from urllib.parse import unquote_plus, urljoin
88

99
import attr
10+
import orjson
1011
import stac_pydantic
11-
from fastapi import HTTPException
12+
from fastapi import HTTPException, Request
1213
from overrides import overrides
1314
from pydantic import ValidationError
15+
from pygeofilter.backends.cql2_json import to_cql2
16+
from pygeofilter.parsers.cql2_text import parse as parse_cql2_text
1417
from stac_pydantic.links import Relations
1518
from stac_pydantic.shared import MimeTypes
16-
from starlette.requests import Request
1719

1820
from stac_fastapi.elasticsearch import serializers
1921
from stac_fastapi.elasticsearch.config import ElasticsearchSettings
@@ -274,9 +276,9 @@ def _return_date(interval_str):
274276

275277
return {"lte": end_date, "gte": start_date}
276278

277-
@overrides
278279
async def get_search(
279280
self,
281+
request: Request,
280282
collections: Optional[List[str]] = None,
281283
ids: Optional[List[str]] = None,
282284
bbox: Optional[List[NumType]] = None,
@@ -287,8 +289,8 @@ async def get_search(
287289
fields: Optional[List[str]] = None,
288290
sortby: Optional[str] = None,
289291
intersects: Optional[str] = None,
290-
# filter: Optional[str] = None, # todo: requires fastapi > 2.3 unreleased
291-
# filter_lang: Optional[str] = None, # todo: requires fastapi > 2.3 unreleased
292+
filter: Optional[str] = None,
293+
filter_lang: Optional[str] = None,
292294
**kwargs,
293295
) -> ItemCollection:
294296
"""Get search results from the database.
@@ -318,17 +320,24 @@ async def get_search(
318320
"bbox": bbox,
319321
"limit": limit,
320322
"token": token,
321-
"query": json.loads(query) if query else query,
323+
"query": orjson.loads(query) if query else query,
322324
}
323325

326+
# this is borrowed from stac-fastapi-pgstac
327+
# Kludgy fix because using factory does not allow alias for filter-lan
328+
query_params = str(request.query_params)
329+
if filter_lang is None:
330+
match = re.search(r"filter-lang=([a-z0-9-]+)", query_params, re.IGNORECASE)
331+
if match:
332+
filter_lang = match.group(1)
333+
324334
if datetime:
325335
base_args["datetime"] = datetime
326336

327337
if intersects:
328-
base_args["intersects"] = json.loads(unquote_plus(intersects))
338+
base_args["intersects"] = orjson.loads(unquote_plus(intersects))
329339

330340
if sortby:
331-
# https://github.com/radiantearth/stac-spec/tree/master/api-spec/extensions/sort#http-get-or-post-form
332341
sort_param = []
333342
for sort in sortby:
334343
sort_param.append(
@@ -339,12 +348,13 @@ async def get_search(
339348
)
340349
base_args["sortby"] = sort_param
341350

342-
# todo: requires fastapi > 2.3 unreleased
343-
# if filter:
344-
# if filter_lang == "cql2-text":
345-
# base_args["filter-lang"] = "cql2-json"
346-
# base_args["filter"] = orjson.loads(to_cql2(parse_cql2_text(filter)))
347-
# print(f'>>> {base_args["filter"]}')
351+
if filter:
352+
if filter_lang == "cql2-text":
353+
base_args["filter-lang"] = "cql2-json"
354+
base_args["filter"] = orjson.loads(to_cql2(parse_cql2_text(filter)))
355+
else:
356+
base_args["filter-lang"] = "cql2-json"
357+
base_args["filter"] = orjson.loads(unquote_plus(filter))
348358

349359
if fields:
350360
includes = set()
@@ -363,13 +373,12 @@ async def get_search(
363373
search_request = self.post_request_model(**base_args)
364374
except ValidationError:
365375
raise HTTPException(status_code=400, detail="Invalid parameters provided")
366-
resp = await self.post_search(search_request, request=kwargs["request"])
376+
resp = await self.post_search(search_request=search_request, request=request)
367377

368378
return resp
369379

370-
@overrides
371380
async def post_search(
372-
self, search_request: BaseSearchPostRequest, **kwargs
381+
self, search_request: BaseSearchPostRequest, request: Request
373382
) -> ItemCollection:
374383
"""
375384
Perform a POST search on the catalog.
@@ -384,7 +393,6 @@ async def post_search(
384393
Raises:
385394
HTTPException: If there is an error with the cql2_json filter.
386395
"""
387-
request: Request = kwargs["request"]
388396
base_url = str(request.base_url)
389397

390398
search = self.database.make_search()
@@ -471,7 +479,7 @@ async def post_search(
471479
filter_kwargs = search_request.fields.filter_fields
472480

473481
items = [
474-
json.loads(stac_pydantic.Item(**feat).json(**filter_kwargs))
482+
orjson.loads(stac_pydantic.Item(**feat).json(**filter_kwargs))
475483
for feat in items
476484
]
477485

stac_fastapi/elasticsearch/tests/extensions/test_filter.py

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
from os import listdir
44
from os.path import isfile, join
55

6+
import pytest
7+
68
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
79

810

9-
async def test_search_filters(app_client, ctx):
11+
@pytest.mark.asyncio
12+
async def test_search_filters_post(app_client, ctx):
1013

1114
filters = []
1215
pwd = f"{THIS_DIR}/cql2"
@@ -19,15 +22,45 @@ async def test_search_filters(app_client, ctx):
1922
assert resp.status_code == 200
2023

2124

22-
async def test_search_filter_extension_eq(app_client, ctx):
25+
@pytest.mark.asyncio
26+
async def test_search_filter_extension_eq_get(app_client, ctx):
27+
resp = await app_client.get(
28+
'/search?filter-lang=cql2-json&filter={"op":"=","args":[{"property":"id"},"test-item"]}'
29+
)
30+
assert resp.status_code == 200
31+
resp_json = resp.json()
32+
assert len(resp_json["features"]) == 1
33+
34+
35+
@pytest.mark.asyncio
36+
async def test_search_filter_extension_eq_post(app_client, ctx):
2337
params = {"filter": {"op": "=", "args": [{"property": "id"}, ctx.item["id"]]}}
2438
resp = await app_client.post("/search", json=params)
2539
assert resp.status_code == 200
2640
resp_json = resp.json()
2741
assert len(resp_json["features"]) == 1
2842

2943

30-
async def test_search_filter_extension_gte(app_client, ctx):
44+
@pytest.mark.asyncio
45+
async def test_search_filter_extension_gte_get(app_client, ctx):
46+
# there's one item that can match, so one of these queries should match it and the other shouldn't
47+
resp = await app_client.get(
48+
'/search?filter-lang=cql2-json&filter={"op":"<=","args":[{"property": "properties.proj:epsg"},32756]}'
49+
)
50+
51+
assert resp.status_code == 200
52+
assert len(resp.json()["features"]) == 1
53+
54+
resp = await app_client.get(
55+
'/search?filter-lang=cql2-json&filter={"op":">","args":[{"property": "properties.proj:epsg"},32756]}'
56+
)
57+
58+
assert resp.status_code == 200
59+
assert len(resp.json()["features"]) == 0
60+
61+
62+
@pytest.mark.asyncio
63+
async def test_search_filter_extension_gte_post(app_client, ctx):
3164
# there's one item that can match, so one of these queries should match it and the other shouldn't
3265
params = {
3366
"filter": {
@@ -58,7 +91,53 @@ async def test_search_filter_extension_gte(app_client, ctx):
5891
assert len(resp.json()["features"]) == 0
5992

6093

61-
async def test_search_filter_ext_and(app_client, ctx):
94+
@pytest.mark.asyncio
95+
async def test_search_filter_ext_and_get(app_client, ctx):
96+
resp = await app_client.get(
97+
'/search?filter-lang=cql2-json&filter={"op":"and","args":[{"op":"<=","args":[{"property":"properties.proj:epsg"},32756]},{"op":"=","args":[{"property":"id"},"test-item"]}]}'
98+
)
99+
100+
assert resp.status_code == 200
101+
assert len(resp.json()["features"]) == 1
102+
103+
104+
@pytest.mark.asyncio
105+
async def test_search_filter_ext_and_get_cql2text_id(app_client, ctx):
106+
collection = ctx.item["collection"]
107+
id = ctx.item["id"]
108+
filter = f"id='{id}' AND collection='{collection}'"
109+
resp = await app_client.get(f"/search?filter-lang=cql2-text&filter={filter}")
110+
111+
assert resp.status_code == 200
112+
assert len(resp.json()["features"]) == 1
113+
114+
115+
@pytest.mark.asyncio
116+
async def test_search_filter_ext_and_get_cql2text_cloud_cover(app_client, ctx):
117+
collection = ctx.item["collection"]
118+
cloud_cover = ctx.item["properties"]["eo:cloud_cover"]
119+
filter = f"cloud_cover={cloud_cover} AND collection='{collection}'"
120+
resp = await app_client.get(f"/search?filter-lang=cql2-text&filter={filter}")
121+
122+
assert resp.status_code == 200
123+
assert len(resp.json()["features"]) == 1
124+
125+
126+
@pytest.mark.asyncio
127+
async def test_search_filter_ext_and_get_cql2text_cloud_cover_no_results(
128+
app_client, ctx
129+
):
130+
collection = ctx.item["collection"]
131+
cloud_cover = ctx.item["properties"]["eo:cloud_cover"] + 1
132+
filter = f"cloud_cover={cloud_cover} AND collection='{collection}'"
133+
resp = await app_client.get(f"/search?filter-lang=cql2-text&filter={filter}")
134+
135+
assert resp.status_code == 200
136+
assert len(resp.json()["features"]) == 0
137+
138+
139+
@pytest.mark.asyncio
140+
async def test_search_filter_ext_and_post(app_client, ctx):
62141
params = {
63142
"filter": {
64143
"op": "and",
@@ -80,7 +159,32 @@ async def test_search_filter_ext_and(app_client, ctx):
80159
assert len(resp.json()["features"]) == 1
81160

82161

83-
async def test_search_filter_extension_floats(app_client, ctx):
162+
@pytest.mark.asyncio
163+
async def test_search_filter_extension_floats_get(app_client, ctx):
164+
resp = await app_client.get(
165+
"""/search?filter={"op":"and","args":[{"op":"=","args":[{"property":"id"},"test-item"]},{"op":">","args":[{"property":"properties.view:sun_elevation"},"-37.30891534"]},{"op":"<","args":[{"property":"properties.view:sun_elevation"},"-37.30691534"]}]}"""
166+
)
167+
168+
assert resp.status_code == 200
169+
assert len(resp.json()["features"]) == 1
170+
171+
resp = await app_client.get(
172+
"""/search?filter={"op":"and","args":[{"op":"=","args":[{"property":"id"},"test-item-7"]},{"op":">","args":[{"property":"properties.view:sun_elevation"},"-37.30891534"]},{"op":"<","args":[{"property":"properties.view:sun_elevation"},"-37.30691534"]}]}"""
173+
)
174+
175+
assert resp.status_code == 200
176+
assert len(resp.json()["features"]) == 0
177+
178+
resp = await app_client.get(
179+
"""/search?filter={"op":"and","args":[{"op":"=","args":[{"property":"id"},"test-item"]},{"op":">","args":[{"property":"properties.view:sun_elevation"},"-37.30591534"]},{"op":"<","args":[{"property":"properties.view:sun_elevation"},"-37.30491534"]}]}"""
180+
)
181+
182+
assert resp.status_code == 200
183+
assert len(resp.json()["features"]) == 0
184+
185+
186+
@pytest.mark.asyncio
187+
async def test_search_filter_extension_floats_post(app_client, ctx):
84188
sun_elevation = ctx.item["properties"]["view:sun_elevation"]
85189

86190
params = {

0 commit comments

Comments
 (0)