diff --git a/README.md b/README.md index 4cea8d0b..a7d885fe 100644 --- a/README.md +++ b/README.md @@ -103,16 +103,21 @@ To run the servers, use ./scripts/server ``` -This will bring up the development database, STAC API, Tiler, Azure Functions, and other services. +This will bring up the development database, STAC API, Tiler, Azure Functions, and other services. If at this point something errors out (e.g. nginx complaining about a config file), try deleting the containers/images and rerunning `./scripts/setup`. -To test the tiler, try going to . +The STAC API can be found at (goes through nginx) or directly. + +To hit the tiler, try going to , although it will fail due to lack of an authorization header. #### Testing and and formatting -To run tests, use +To run tests, use one of the following (note, you don't need `./scripts/server` running). If you get an immediate error related to library stubs, just run it again. The tiler tests may fail locally, TBD why. ```console ./scripts/test +./scripts/test --stac +./scripts/test --tiler +./scripts/test --common ``` To format code, use diff --git a/pcstac/pcstac/client.py b/pcstac/pcstac/client.py index 3568692f..dfc30eeb 100644 --- a/pcstac/pcstac/client.py +++ b/pcstac/pcstac/client.py @@ -4,7 +4,8 @@ from urllib.parse import urljoin import attr -from fastapi import Request +import orjson +from fastapi import HTTPException, Request from stac_fastapi.pgstac.core import CoreCrudClient from stac_fastapi.types.errors import NotFoundError from stac_fastapi.types.stac import ( @@ -215,7 +216,17 @@ async def _fetch() -> ItemCollection: ) return item_collection + # Block searches that don't specify a collection + if ( + search_request.collections is None + and "collection=" not in str(request.url) + and '{"property":"collection"}' + not in orjson.dumps(search_request.filter).decode("utf-8") + ): + raise HTTPException(status_code=422, detail="collection is required") + search_json = search_request.model_dump_json() + add_stac_attributes_from_search(search_json, request) logger.info( diff --git a/pcstac/tests/resources/test_item.py b/pcstac/tests/resources/test_item.py index 5a80fb02..c7207e63 100644 --- a/pcstac/tests/resources/test_item.py +++ b/pcstac/tests/resources/test_item.py @@ -223,6 +223,7 @@ async def test_item_search_bbox_get(app_client): assert resp_json["features"][0]["id"] == first_item["id"] +# @pytest.mark.skip(reason="TODO") @pytest.mark.asyncio async def test_item_search_get_without_collections(app_client): """Test GET search without specifying collections""" @@ -234,9 +235,7 @@ async def test_item_search_get_without_collections(app_client): "bbox": ",".join([str(coord) for coord in first_item["bbox"]]), } resp = await app_client.get("/search", params=params) - assert resp.status_code == 200 - resp_json = resp.json() - assert resp_json["features"][0]["id"] == first_item["id"] + assert resp.status_code == 422 # Unprocessable Content @pytest.mark.asyncio @@ -299,9 +298,7 @@ async def test_item_search_post_without_collection(app_client): "bbox": first_item["bbox"], } resp = await app_client.post("/search", json=params) - assert resp.status_code == 200 - resp_json = resp.json() - assert resp_json["features"][0]["id"] == first_item["id"] + assert resp.status_code == 422 # Unprocessable Content @pytest.mark.asyncio @@ -313,7 +310,10 @@ async def test_item_search_properties_jsonb(app_client): first_item = items_resp.json()["features"][0] # EPSG is a JSONB key - params = {"query": {"proj:epsg": {"eq": first_item["properties"]["proj:epsg"]}}} + params = { + "collections": [first_item["collection"]], + "query": {"proj:epsg": {"eq": first_item["properties"]["proj:epsg"]}}, + } print(params) resp = await app_client.post("/search", json=params) assert resp.status_code == 200 @@ -395,6 +395,69 @@ async def test_item_search_get_filter_extension_cql(app_client): ) +@pytest.mark.asyncio +async def test_search_using_filter_with_collectionid(app_client): + """Test POST search with JSONB query (cql json filter extension) + that includes a collectionid in the filter and no where else""" + items_resp = await app_client.get("/collections/naip/items") + assert items_resp.status_code == 200 + + first_item = items_resp.json()["features"][0] + + # EPSG is a JSONB key + body = { + "filter": { + "op": "and", + "args": [ + {"op": "=", "args": [{"property": "collection"}, "naip"]}, + { + "op": "=", + "args": [ + {"property": "proj:epsg"}, + first_item["properties"]["proj:epsg"], + ], + }, + ], + } + } + resp = await app_client.post("/search", json=body) + resp_json = resp.json() + + assert resp.status_code == 200 + assert len(resp_json["features"]) == 12 + assert ( + resp_json["features"][0]["properties"]["proj:epsg"] + == first_item["properties"]["proj:epsg"] + ) + + +@pytest.mark.asyncio +async def test_search_using_filter_without_collectionid(app_client): + """Test POST search with JSONB query (cql json filter extension) + that includes a collectionid in the filter and no where else""" + items_resp = await app_client.get("/collections/naip/items") + assert items_resp.status_code == 200 + + first_item = items_resp.json()["features"][0] + + # EPSG is a JSONB key + body = { + "filter": { + "args": [ + { + "op": "=", + "args": [ + {"property": "proj:epsg"}, + first_item["properties"]["proj:epsg"], + ], + }, + ], + } + } + resp = await app_client.post("/search", json=body) + assert resp.status_code == 422 + + @pytest.mark.asyncio async def test_get_missing_item_collection(app_client): """Test reading a collection which does not exist""" @@ -459,7 +522,7 @@ async def test_pagination_post(app_client): ids = [item["id"] for item in items_resp.json()["features"]] # Paginate through all 5 items with a limit of 1 (expecting 5 requests) - request_body = {"ids": ids, "limit": 1} + request_body = {"ids": ids, "limit": 1, "collections": ["naip"]} page = await app_client.post("/search", json=request_body) idx = 0 item_ids = [] @@ -489,7 +552,11 @@ async def test_pagination_token_idempotent(app_client): # so that a "next" link is returned page = await app_client.get( "/search", - params={"datetime": "1900-01-01T00:00:00Z/2030-01-01T00:00:00Z", "limit": 3}, + params={ + "datetime": "1900-01-01T00:00:00Z/2030-01-01T00:00:00Z", + "limit": 3, + "collections": ["naip"], + }, ) assert page.status_code == 200 @@ -516,7 +583,10 @@ async def test_pagination_token_idempotent(app_client): @pytest.mark.asyncio async def test_field_extension_get(app_client): """Test GET search with included fields (fields extension)""" - params = {"fields": "+properties.proj:epsg,+properties.gsd,+collection"} + params = { + "fields": "+properties.proj:epsg,+properties.gsd,+collection", + "collections": ["naip"], + } resp = await app_client.get("/search", params=params) print(resp.json()) feat_properties = resp.json()["features"][0]["properties"] @@ -526,7 +596,7 @@ async def test_field_extension_get(app_client): @pytest.mark.asyncio async def test_field_extension_exclude_default_includes(app_client): """Test POST search excluding a forbidden field (fields extension)""" - body = {"fields": {"exclude": ["geometry"]}} + body = {"fields": {"exclude": ["geometry"]}, "collections": ["naip"]} resp = await app_client.post("/search", json=body) resp_json = resp.json() @@ -538,7 +608,7 @@ async def test_search_intersects_and_bbox(app_client): """Test POST search intersects and bbox are mutually exclusive (core)""" bbox = [-118, 34, -117, 35] geoj = Polygon.from_bounds(*bbox).model_dump(exclude_none=True) - params = {"bbox": bbox, "intersects": geoj} + params = {"bbox": bbox, "intersects": geoj, "collections": ["naip"]} resp = await app_client.post("/search", json=params) assert resp.status_code == 400 @@ -599,15 +669,18 @@ async def test_tiler_link_construction(app_client): @pytest.mark.asyncio async def test_search_bbox_errors(app_client): - body = {"query": {"bbox": [0]}} + body = {"query": {"bbox": [0]}, "collections": ["naip"]} resp = await app_client.post("/search", json=body) assert resp.status_code == 400 - body = {"query": {"bbox": [100.0, 0.0, 0.0, 105.0, 1.0, 1.0]}} + body = { + "query": {"bbox": [100.0, 0.0, 0.0, 105.0, 1.0, 1.0]}, + "collections": ["naip"], + } resp = await app_client.post("/search", json=body) assert resp.status_code == 400 - params = {"bbox": "100.0,0.0,0.0,105.0"} + params = {"bbox": "100.0,0.0,0.0,105.0", "collections": ["naip"]} resp = await app_client.get("/search", params=params) assert resp.status_code == 400 @@ -628,6 +701,9 @@ async def test_search_get_page_limits(app_client): assert len(resp_json["features"]) == 12 +@pytest.mark.skip( + reason="Are these params even valid? they are not within filter field" +) @pytest.mark.asyncio async def test_search_post_page_limits(app_client): params = {"op": "=", "args": [{"property": "collection"}, "naip"]}