diff --git a/docker-compose.yml b/docker-compose.yml index 4c84c7089..4e697a02e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,7 +71,7 @@ services: database: container_name: stac-db - image: ghcr.io/stac-utils/pgstac:v0.3.4 + image: ghcr.io/stac-utils/pgstac:v0.4.0 environment: - POSTGRES_USER=username - POSTGRES_PASSWORD=password diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 704cf6ed0..1a23add72 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -86,12 +86,12 @@ def create_get_request_model( def create_post_request_model( - extensions, base_model: BaseSearchPostRequest = BaseSearchGetRequest + extensions, base_model: BaseSearchPostRequest = BaseSearchPostRequest ): """Wrap create_request_model to create the POST request model.""" return create_request_model( "SearchPostRequest", - base_model=BaseSearchPostRequest, + base_model=base_model, extensions=extensions, request_type="POST", ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py index afd5b947c..26e8b5797 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py @@ -1,13 +1,28 @@ """Filter extension request models.""" +from enum import Enum from typing import Any, Dict, Optional import attr -from pydantic import BaseModel +from pydantic import BaseModel, Field from stac_fastapi.types.search import APIRequest +class FilterLang(str, Enum): + """Choices for filter-lang value in a POST request. + + Based on https://github.com/radiantearth/stac-api-spec/tree/master/fragments/filter#queryables + + Note the addition of cql2-json, which is used by the pgstac backend, + but is not included in the spec above. + """ + + cql_json = "cql-json" + cql2_json = "cql2-json" + cql_text = "cql-text" + + @attr.s class FilterExtensionGetRequest(APIRequest): """Filter extension GET request model.""" @@ -19,3 +34,5 @@ class FilterExtensionPostRequest(BaseModel): """Filter extension POST request model.""" filter: Optional[Dict[str, Any]] = None + filter_crs: Optional[str] = Field(alias="filter-crs", default=None) + filter_lang: Optional[FilterLang] = Field(alias="filter-lang", default=None) diff --git a/stac_fastapi/pgstac/setup.py b/stac_fastapi/pgstac/setup.py index 10d5c810e..7472d0693 100644 --- a/stac_fastapi/pgstac/setup.py +++ b/stac_fastapi/pgstac/setup.py @@ -25,7 +25,7 @@ "pytest-asyncio", "pre-commit", "requests", - "pypgstac==0.3.4", + "pypgstac==0.4.0", "httpx", "shapely", ], diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py index c0ba8e183..b05afce51 100644 --- a/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py +++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py @@ -120,7 +120,7 @@ async def _search_base( pool = request.app.state.readpool # pool = kwargs["request"].app.state.readpool - req = search_request.json(exclude_none=True) + req = search_request.json(exclude_none=True, by_alias=True) try: async with pool.acquire() as conn: diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/types/search.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/types/search.py index a13126f1e..f63057077 100644 --- a/stac_fastapi/pgstac/stac_fastapi/pgstac/types/search.py +++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/types/search.py @@ -1,6 +1,6 @@ """stac_fastapi.types.search module.""" -from typing import Optional +from typing import Dict, Optional from pydantic import validator @@ -14,8 +14,30 @@ class PgstacSearch(BaseSearchPostRequest): """ datetime: Optional[str] = None + conf: Optional[Dict] = None @validator("datetime") def validate_datetime(cls, v): """Pgstac does not require the base validator for datetime.""" return v + + @validator("filter_lang", pre=False, check_fields=False, always=True) + def validate_query_uses_cql(cls, v, values): + """If using query syntax, forces cql-json.""" + retval = v + if values.get("query", None) is not None: + retval = "cql-json" + if values.get("collections", None) is not None: + retval = "cql-json" + if values.get("ids", None) is not None: + retval = "cql-json" + if values.get("datetime", None) is not None: + retval = "cql-json" + if values.get("bbox", None) is not None: + retval = "cql-json" + if v == "cql2-json" and retval == "cql-json": + raise ValueError( + "query, collections, ids, datetime, and bbox" + "parameters are not available in cql2-json" + ) + return retval diff --git a/stac_fastapi/pgstac/tests/conftest.py b/stac_fastapi/pgstac/tests/conftest.py index 12f8274f2..d2fd9a00d 100644 --- a/stac_fastapi/pgstac/tests/conftest.py +++ b/stac_fastapi/pgstac/tests/conftest.py @@ -45,7 +45,10 @@ async def pg(): try: await conn.execute("CREATE DATABASE pgstactestdb;") await conn.execute( - "ALTER DATABASE pgstactestdb SET search_path to pgstac, public;" + """ + ALTER DATABASE pgstactestdb SET search_path to pgstac, public; + ALTER DATABASE pgstactestdb SET log_statement to 'all'; + """ ) except asyncpg.exceptions.DuplicateDatabaseError: await conn.execute("DROP DATABASE pgstactestdb;") @@ -78,7 +81,14 @@ async def pgstac(pg): yield print("Truncating Data") conn = await asyncpg.connect(dsn=settings.testing_connection_string) - await conn.execute("TRUNCATE items CASCADE; TRUNCATE collections CASCADE;") + await conn.execute( + """ + TRUNCATE pgstac.items CASCADE; + TRUNCATE pgstac.collections CASCADE; + TRUNCATE pgstac.searches CASCADE; + TRUNCATE pgstac.search_wheres CASCADE; + """ + ) await conn.close() diff --git a/stac_fastapi/pgstac/tests/resources/test_item.py b/stac_fastapi/pgstac/tests/resources/test_item.py index af5213ae6..baa8b2b82 100644 --- a/stac_fastapi/pgstac/tests/resources/test_item.py +++ b/stac_fastapi/pgstac/tests/resources/test_item.py @@ -1029,3 +1029,124 @@ async def test_preserves_extra_link( extra_link = [link for link in item["links"] if link["rel"] == "preview"] assert extra_link assert extra_link[0]["href"] == expected_href + + +@pytest.mark.asyncio +async def test_item_search_get_filter_extension_cql_explicitlang( + app_client, load_test_data, load_test_collection +): + """Test GET search with JSONB query (cql json filter extension)""" + test_item = load_test_data("test_item.json") + resp = await app_client.post( + f"/collections/{test_item['collection']}/items", json=test_item + ) + assert resp.status_code == 200 + + # EPSG is a JSONB key + params = { + "collections": [test_item["collection"]], + "filter-lang": "cql-json", + "filter": { + "gt": [ + {"property": "proj:epsg"}, + test_item["properties"]["proj:epsg"] + 1, + ] + }, + } + resp = await app_client.post("/search", json=params) + resp_json = resp.json() + + assert resp.status_code == 200 + assert len(resp_json.get("features")) == 0 + + params = { + "collections": [test_item["collection"]], + "filter-lang": "cql-json", + "filter": { + "eq": [ + {"property": "proj:epsg"}, + test_item["properties"]["proj:epsg"], + ] + }, + } + resp = await app_client.post("/search", json=params) + resp_json = resp.json() + assert len(resp.json()["features"]) == 1 + assert ( + resp_json["features"][0]["properties"]["proj:epsg"] + == test_item["properties"]["proj:epsg"] + ) + + +@pytest.mark.asyncio +async def test_item_search_get_filter_extension_cql2( + app_client, load_test_data, load_test_collection +): + """Test GET search with JSONB query (cql json filter extension)""" + test_item = load_test_data("test_item.json") + resp = await app_client.post( + f"/collections/{test_item['collection']}/items", json=test_item + ) + assert resp.status_code == 200 + + # EPSG is a JSONB key + params = { + "filter-lang": "cql2-json", + "filter": { + "op": "and", + "args": [ + { + "op": "eq", + "args": [ + {"property": "proj:epsg"}, + test_item["properties"]["proj:epsg"] + 1, + ], + }, + { + "op": "in", + "args": [ + {"property": "collection"}, + [test_item["collection"]], + ], + }, + ], + }, + } + print(json.dumps(params)) + resp = await app_client.post("/search", json=params) + resp_json = resp.json() + print(resp_json) + + assert resp.status_code == 200 + assert len(resp_json.get("features")) == 0 + + params = { + "filter-lang": "cql2-json", + "filter": { + "op": "and", + "args": [ + { + "op": "eq", + "args": [ + {"property": "proj:epsg"}, + test_item["properties"]["proj:epsg"], + ], + }, + { + "op": "in", + "args": [ + {"property": "collection"}, + [test_item["collection"]], + ], + }, + ], + }, + } + resp = await app_client.post("/search", json=params) + resp_json = resp.json() + print(resp_json) + assert len(resp.json()["features"]) == 1 + assert ( + resp_json["features"][0]["properties"]["proj:epsg"] + == test_item["properties"]["proj:epsg"] + ) diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index 3ef9a80c1..ec1bb9579 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -73,7 +73,7 @@ class BaseSearchGetRequest(APIRequest): ids: Optional[str] = attr.ib(default=None, converter=str2list) bbox: Optional[str] = attr.ib(default=None, converter=str2list) intersects: Optional[str] = attr.ib(default=None, converter=str2list) - datetime: Optional[Union[str]] = attr.ib(default=None) + datetime: Optional[Union[str, Dict]] = attr.ib(default=None) limit: Optional[int] = attr.ib(default=10)