From d2fcf1337e1895f882545ab5839d6514af5bd238 Mon Sep 17 00:00:00 2001 From: Jon Duckworth Date: Tue, 2 Aug 2022 13:50:09 -0400 Subject: [PATCH] Use collection_id path parameter Items Transactions endpoints (#425) * Add collection_id path parameter and check against Item collection property * Fix unformatted f-strings * Fix Item PUT endpoint per #385 * Update API tests to use new PUT paths * Make equivalent changes to sqlalchemy backend * Add CHANGELOG entry for #425 * Fix failing tests from previous merge * Return 400 for Item id or collection conflicts --- CHANGES.md | 6 ++ scripts/ingest_joplin.py | 3 +- stac_fastapi/api/tests/test_api.py | 4 +- .../extensions/core/transaction.py | 22 ++++- .../stac_fastapi/pgstac/transactions.py | 25 +++++- stac_fastapi/pgstac/tests/api/test_api.py | 2 +- .../pgstac/tests/clients/test_postgres.py | 4 +- stac_fastapi/pgstac/tests/conftest.py | 6 +- .../pgstac/tests/resources/test_item.py | 70 ++++++++++++--- .../stac_fastapi/sqlalchemy/transactions.py | 34 +++++-- stac_fastapi/sqlalchemy/tests/api/test_api.py | 90 ++++++++++++++----- .../sqlalchemy/tests/clients/test_postgres.py | 42 ++++++--- .../sqlalchemy/tests/resources/test_item.py | 22 +++-- stac_fastapi/testdata/joplin/feature.geojson | 59 ++++++++++++ stac_fastapi/types/stac_fastapi/types/core.py | 8 +- stac_fastapi/types/stac_fastapi/types/stac.py | 11 ++- 16 files changed, 332 insertions(+), 76 deletions(-) create mode 100644 stac_fastapi/testdata/joplin/feature.geojson diff --git a/CHANGES.md b/CHANGES.md index b653aee3f..9e51040f1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -22,6 +22,9 @@ * docker-compose now runs uvicorn with hot-reloading enabled * Bump version of PGStac to 0.6.2 that includes support for hydrating results in the API backed ([#397](https://github.com/stac-utils/stac-fastapi/pull/397)) * Make item geometry and bbox nullable in sqlalchemy backend. ([#398](https://github.com/stac-utils/stac-fastapi/pull/398)) +* Transactions Extension update Item endpoint Item is now `/collections/{collection_id}/items/{item_id}` instead of + `/collections/{collection_id}/items` to align with [STAC API + spec](https://github.com/radiantearth/stac-api-spec/tree/main/ogcapi-features/extensions/transaction#methods) ([#425](https://github.com/stac-utils/stac-fastapi/pull/425)) ### Removed * Remove the unused `router_middleware` function ([#439](https://github.com/stac-utils/stac-fastapi/pull/439)) @@ -36,6 +39,9 @@ * SQLAlchemy backend bulk item insert now works ([#356](https://github.com/stac-utils/stac-fastapi/issues/356)) * PGStac Backend has stricter implementation of Fields Extension syntax ([#397](https://github.com/stac-utils/stac-fastapi/pull/397)) * `/queryables` endpoint now has type `application/schema+json` instead of `application/json` ([#421](https://github.com/stac-utils/stac-fastapi/pull/421)) +* Transactions Extension update Item endpoint validates that the `{collection_id}` path parameter matches the Item `"collection"` property + from the request body, if present, and falls back to using the path parameter if no `"collection"` property is found in the body + ([#425](https://github.com/stac-utils/stac-fastapi/pull/425)) ## [2.3.0] diff --git a/scripts/ingest_joplin.py b/scripts/ingest_joplin.py index 727546694..25f32d1eb 100644 --- a/scripts/ingest_joplin.py +++ b/scripts/ingest_joplin.py @@ -19,8 +19,9 @@ def post_or_put(url: str, data: dict): """Post or put data to url.""" r = requests.post(url, json=data) if r.status_code == 409: + new_url = url if data["type"] == "Collection" else url + f"/{data['id']}" # Exists, so update - r = requests.put(url, json=data) + r = requests.put(new_url, json=data) # Unchanged may throw a 404 if not r.status_code == 404: r.raise_for_status() diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py index 7d3406568..ab5a304d4 100644 --- a/stac_fastapi/api/tests/test_api.py +++ b/stac_fastapi/api/tests/test_api.py @@ -55,7 +55,7 @@ def test_build_api_with_route_dependencies(self): {"path": "/collections", "method": "PUT"}, {"path": "/collections/{collectionId}", "method": "DELETE"}, {"path": "/collections/{collectionId}/items", "method": "POST"}, - {"path": "/collections/{collectionId}/items", "method": "PUT"}, + {"path": "/collections/{collectionId}/items/{itemId}", "method": "PUT"}, {"path": "/collections/{collectionId}/items/{itemId}", "method": "DELETE"}, ] dependencies = [Depends(must_be_bob)] @@ -68,7 +68,7 @@ def test_add_route_dependencies_after_building_api(self): {"path": "/collections", "method": "PUT"}, {"path": "/collections/{collectionId}", "method": "DELETE"}, {"path": "/collections/{collectionId}/items", "method": "POST"}, - {"path": "/collections/{collectionId}/items", "method": "PUT"}, + {"path": "/collections/{collectionId}/items/{itemId}", "method": "PUT"}, {"path": "/collections/{collectionId}/items/{itemId}", "method": "DELETE"}, ] api = self._build_api() diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py index f446de2be..5967e7128 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -2,7 +2,7 @@ from typing import Callable, List, Optional, Type, Union import attr -from fastapi import APIRouter, FastAPI +from fastapi import APIRouter, Body, FastAPI from pydantic import BaseModel from stac_pydantic import Collection, Item from starlette.responses import JSONResponse, Response @@ -15,6 +15,20 @@ from stac_fastapi.types.extension import ApiExtension +@attr.s +class PostItem(CollectionUri): + """Create Item.""" + + item: stac_types.Item = attr.ib(default=Body()) + + +@attr.s +class PutItem(ItemUri): + """Update Item.""" + + item: stac_types.Item = attr.ib(default=Body()) + + @attr.s class TransactionExtension(ApiExtension): """Transaction Extension. @@ -77,20 +91,20 @@ def register_create_item(self): response_model_exclude_unset=True, response_model_exclude_none=True, methods=["POST"], - endpoint=self._create_endpoint(self.client.create_item, stac_types.Item), + endpoint=self._create_endpoint(self.client.create_item, PostItem), ) def register_update_item(self): """Register update item endpoint (PUT /collections/{collection_id}/items).""" self.router.add_api_route( name="Update Item", - path="/collections/{collection_id}/items", + path="/collections/{collection_id}/items/{item_id}", response_model=Item if self.settings.enable_response_models else None, response_class=self.response_class, response_model_exclude_unset=True, response_model_exclude_none=True, methods=["PUT"], - endpoint=self._create_endpoint(self.client.update_item, stac_types.Item), + endpoint=self._create_endpoint(self.client.update_item, PutItem), ) def register_delete_item(self): diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/transactions.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/transactions.py index 699912440..d2b2e9810 100644 --- a/stac_fastapi/pgstac/stac_fastapi/pgstac/transactions.py +++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/transactions.py @@ -4,6 +4,7 @@ from typing import Optional, Union import attr +from fastapi import HTTPException from starlette.responses import JSONResponse, Response from stac_fastapi.extensions.third_party.bulk_transactions import ( @@ -23,18 +24,38 @@ class TransactionsClient(AsyncBaseTransactionsClient): """Transactions extension specific CRUD operations.""" async def create_item( - self, item: stac_types.Item, **kwargs + self, collection_id: str, item: stac_types.Item, **kwargs ) -> Optional[Union[stac_types.Item, Response]]: """Create item.""" + body_collection_id = item.get("collection") + if body_collection_id is not None and collection_id != body_collection_id: + raise HTTPException( + status_code=400, + detail=f"Collection ID from path parameter ({collection_id}) does not match Collection ID from Item ({body_collection_id})", + ) + item["collection"] = collection_id request = kwargs["request"] pool = request.app.state.writepool await dbfunc(pool, "create_item", item) return item async def update_item( - self, item: stac_types.Item, **kwargs + self, collection_id: str, item_id: str, item: stac_types.Item, **kwargs ) -> Optional[Union[stac_types.Item, Response]]: """Update item.""" + body_collection_id = item.get("collection") + if body_collection_id is not None and collection_id != body_collection_id: + raise HTTPException( + status_code=400, + detail=f"Collection ID from path parameter ({collection_id}) does not match Collection ID from Item ({body_collection_id})", + ) + item["collection"] = collection_id + body_item_id = item["id"] + if body_item_id != item_id: + raise HTTPException( + status_code=400, + detail=f"Item ID from path parameter ({item_id}) does not match Item ID from Item ({body_item_id})", + ) request = kwargs["request"] pool = request.app.state.writepool await dbfunc(pool, "update_item", item) diff --git a/stac_fastapi/pgstac/tests/api/test_api.py b/stac_fastapi/pgstac/tests/api/test_api.py index 369f9f0ce..4ace9496d 100644 --- a/stac_fastapi/pgstac/tests/api/test_api.py +++ b/stac_fastapi/pgstac/tests/api/test_api.py @@ -19,7 +19,7 @@ "POST /collections", "POST /collections/{collection_id}/items", "PUT /collections", - "PUT /collections/{collection_id}/items", + "PUT /collections/{collection_id}/items/{item_id}", ] diff --git a/stac_fastapi/pgstac/tests/clients/test_postgres.py b/stac_fastapi/pgstac/tests/clients/test_postgres.py index 77089ba74..345dc4f9a 100644 --- a/stac_fastapi/pgstac/tests/clients/test_postgres.py +++ b/stac_fastapi/pgstac/tests/clients/test_postgres.py @@ -76,7 +76,9 @@ async def test_update_item(app_client, load_test_collection, load_test_item): item.properties.description = "Update Test" - resp = await app_client.put(f"/collections/{coll.id}/items", content=item.json()) + resp = await app_client.put( + f"/collections/{coll.id}/items/{item.id}", content=item.json() + ) assert resp.status_code == 200 resp = await app_client.get(f"/collections/{coll.id}/items/{item.id}") diff --git a/stac_fastapi/pgstac/tests/conftest.py b/stac_fastapi/pgstac/tests/conftest.py index 4a32fd73e..ed3f970eb 100644 --- a/stac_fastapi/pgstac/tests/conftest.py +++ b/stac_fastapi/pgstac/tests/conftest.py @@ -201,9 +201,10 @@ async def load_test_collection(app_client, load_test_data): @pytest.fixture async def load_test_item(app_client, load_test_data, load_test_collection): + coll = load_test_collection data = load_test_data("test_item.json") resp = await app_client.post( - "/collections/{coll.id}/items", + f"/collections/{coll.id}/items", json=data, ) assert resp.status_code == 200 @@ -223,9 +224,10 @@ async def load_test2_collection(app_client, load_test_data): @pytest.fixture async def load_test2_item(app_client, load_test_data, load_test2_collection): + coll = load_test2_collection data = load_test_data("test2_item.json") resp = await app_client.post( - "/collections/{coll.id}/items", + f"/collections/{coll.id}/items", json=data, ) assert resp.status_code == 200 diff --git a/stac_fastapi/pgstac/tests/resources/test_item.py b/stac_fastapi/pgstac/tests/resources/test_item.py index d261738ab..5ce39a73c 100644 --- a/stac_fastapi/pgstac/tests/resources/test_item.py +++ b/stac_fastapi/pgstac/tests/resources/test_item.py @@ -1,7 +1,9 @@ import json +import random import uuid from datetime import timedelta from http.client import HTTP_PORT +from string import ascii_letters from typing import Callable from urllib.parse import parse_qs, urljoin, urlparse @@ -81,6 +83,24 @@ async def test_create_item(app_client, load_test_data: Callable, load_test_colle assert in_item.dict(exclude={"links"}) == get_item.dict(exclude={"links"}) +async def test_create_item_mismatched_collection_id( + app_client, load_test_data: Callable, load_test_collection +): + # If the collection_id path parameter and the Item's "collection" property do not match, a 400 response should + # be returned. + coll = load_test_collection + + in_json = load_test_data("test_item.json") + in_json["collection"] = random.choice(ascii_letters) + assert in_json["collection"] != coll.id + + resp = await app_client.post( + f"/collections/{coll.id}/items", + json=in_json, + ) + assert resp.status_code == 400 + + async def test_fetches_valid_item( app_client, load_test_data: Callable, load_test_collection ): @@ -89,7 +109,7 @@ async def test_fetches_valid_item( in_json = load_test_data("test_item.json") in_item = Item.parse_obj(in_json) resp = await app_client.post( - "/collections/{coll.id}/items", + f"/collections/{coll.id}/items", json=in_json, ) assert resp.status_code == 200 @@ -117,7 +137,9 @@ async def test_update_item( item.properties.description = "Update Test" - resp = await app_client.put(f"/collections/{coll.id}/items", content=item.json()) + resp = await app_client.put( + f"/collections/{coll.id}/items/{item.id}", content=item.json() + ) assert resp.status_code == 200 resp = await app_client.get(f"/collections/{coll.id}/items/{item.id}") @@ -128,6 +150,25 @@ async def test_update_item( assert get_item.properties.description == "Update Test" +async def test_update_item_mismatched_collection_id( + app_client, load_test_data: Callable, load_test_collection, load_test_item +) -> None: + coll = load_test_collection + + in_json = load_test_data("test_item.json") + + in_json["collection"] = random.choice(ascii_letters) + assert in_json["collection"] != coll.id + + item_id = in_json["id"] + + resp = await app_client.put( + f"/collections/{coll.id}/items/{item_id}", + json=in_json, + ) + assert resp.status_code == 400 + + async def test_delete_item( app_client, load_test_data: Callable, load_test_collection, load_test_item ): @@ -165,18 +206,17 @@ async def test_get_collection_items(app_client, load_test_collection, load_test_ async def test_create_item_conflict( app_client, load_test_data: Callable, load_test_collection ): - pass - + coll = load_test_collection in_json = load_test_data("test_item.json") Item.parse_obj(in_json) resp = await app_client.post( - "/collections/{coll.id}/items", + f"/collections/{coll.id}/items", json=in_json, ) assert resp.status_code == 200 resp = await app_client.post( - "/collections/{coll.id}/items", + f"/collections/{coll.id}/items", json=in_json, ) assert resp.status_code == 409 @@ -203,7 +243,10 @@ async def test_create_item_missing_collection( item["collection"] = None resp = await app_client.post(f"/collections/{coll.id}/items", json=item) - assert resp.status_code == 424 + assert resp.status_code == 200 + + post_item = resp.json() + assert post_item["collection"] == coll.id async def test_update_new_item( @@ -213,7 +256,9 @@ async def test_update_new_item( item = load_test_item item.id = "test-updatenewitem" - resp = await app_client.put(f"/collections/{coll.id}/items", content=item.json()) + resp = await app_client.put( + f"/collections/{coll.id}/items/{item.id}", content=item.json() + ) assert resp.status_code == 404 @@ -224,8 +269,13 @@ async def test_update_item_missing_collection( item = load_test_item item.collection = None - resp = await app_client.put(f"/collections/{coll.id}/items", content=item.json()) - assert resp.status_code == 424 + resp = await app_client.put( + f"/collections/{coll.id}/items/{item.id}", content=item.json() + ) + assert resp.status_code == 200 + + put_item = resp.json() + assert put_item["collection"] == coll.id async def test_pagination(app_client, load_test_data, load_test_collection): diff --git a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/transactions.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/transactions.py index 1ae1d6f2e..644b82f2d 100644 --- a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/transactions.py +++ b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/transactions.py @@ -4,6 +4,7 @@ from typing import Optional, Type, Union import attr +from fastapi import HTTPException from starlette.responses import Response from stac_fastapi.extensions.third_party.bulk_transactions import ( @@ -35,19 +36,29 @@ class TransactionsClient(BaseTransactionsClient): ) def create_item( - self, model: Union[stac_types.Item, stac_types.ItemCollection], **kwargs + self, + collection_id: str, + item: Union[stac_types.Item, stac_types.ItemCollection], + **kwargs, ) -> Optional[stac_types.Item]: """Create item.""" base_url = str(kwargs["request"].base_url) # If a feature collection is posted - if model["type"] == "FeatureCollection": + if item["type"] == "FeatureCollection": bulk_client = BulkTransactionsClient(session=self.session) - bulk_client.bulk_item_insert(items=model["features"]) + bulk_client.bulk_item_insert(items=item["features"]) return None # Otherwise a single item has been posted - data = self.item_serializer.stac_to_db(model) + body_collection_id = item.get("collection") + if body_collection_id is not None and collection_id != body_collection_id: + raise HTTPException( + status_code=400, + detail=f"Collection ID from path parameter ({collection_id}) does not match Collection ID from Item ({body_collection_id})", + ) + item["collection"] = collection_id + data = self.item_serializer.stac_to_db(item) with self.session.writer.context_session() as session: session.add(data) return self.item_serializer.db_to_stac(data, base_url) @@ -63,9 +74,22 @@ def create_collection( return self.collection_serializer.db_to_stac(data, base_url=base_url) def update_item( - self, item: stac_types.Item, **kwargs + self, collection_id: str, item_id: str, item: stac_types.Item, **kwargs ) -> Optional[Union[stac_types.Item, Response]]: """Update item.""" + body_collection_id = item.get("collection") + if body_collection_id is not None and collection_id != body_collection_id: + raise HTTPException( + status_code=400, + detail=f"Collection ID from path parameter ({collection_id}) does not match Collection ID from Item ({body_collection_id})", + ) + item["collection"] = collection_id + body_item_id = item["id"] + if body_item_id != item_id: + raise HTTPException( + status_code=400, + detail=f"Item ID from path parameter ({item_id}) does not match Item ID from Item ({body_item_id})", + ) base_url = str(kwargs["request"].base_url) with self.session.reader.context_session() as session: query = session.query(self.item_table).filter( diff --git a/stac_fastapi/sqlalchemy/tests/api/test_api.py b/stac_fastapi/sqlalchemy/tests/api/test_api.py index ba7f5a655..093041691 100644 --- a/stac_fastapi/sqlalchemy/tests/api/test_api.py +++ b/stac_fastapi/sqlalchemy/tests/api/test_api.py @@ -19,7 +19,7 @@ "POST /collections", "POST /collections/{collection_id}/items", "PUT /collections", - "PUT /collections/{collection_id}/items", + "PUT /collections/{collection_id}/items/{item_id}", ] @@ -66,7 +66,9 @@ def test_app_transaction_extension(app_client, load_test_data): def test_app_search_response(load_test_data, app_client, postgres_transactions): item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + item["collection"], item, request=MockStarletteRequest + ) resp = app_client.get("/search", params={"collections": ["test-collection"]}) assert resp.status_code == 200 @@ -82,7 +84,9 @@ def test_app_search_response_multipolygon( load_test_data, app_client, postgres_transactions ): item = load_test_data("test_item_multipolygon.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + item["collection"], item, request=MockStarletteRequest + ) resp = app_client.get("/search", params={"collections": ["test-collection"]}) assert resp.status_code == 200 @@ -96,7 +100,9 @@ def test_app_search_response_geometry_null( load_test_data, app_client, postgres_transactions ): item = load_test_data("test_item_geometry_null.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + item["collection"], item, request=MockStarletteRequest + ) resp = app_client.get("/search", params={"collections": ["test-collection"]}) assert resp.status_code == 200 @@ -109,7 +115,9 @@ def test_app_search_response_geometry_null( def test_app_context_extension(load_test_data, app_client, postgres_transactions): item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + item["collection"], item, request=MockStarletteRequest + ) resp = app_client.get("/search", params={"collections": ["test-collection"]}) assert resp.status_code == 200 @@ -120,7 +128,9 @@ def test_app_context_extension(load_test_data, app_client, postgres_transactions def test_app_fields_extension(load_test_data, app_client, postgres_transactions): item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + item["collection"], item, request=MockStarletteRequest + ) resp = app_client.get("/search", params={"collections": ["test-collection"]}) assert resp.status_code == 200 @@ -130,7 +140,9 @@ def test_app_fields_extension(load_test_data, app_client, postgres_transactions) def test_app_query_extension_gt(load_test_data, app_client, postgres_transactions): test_item = load_test_data("test_item.json") - postgres_transactions.create_item(test_item, request=MockStarletteRequest) + postgres_transactions.create_item( + test_item["collection"], test_item, request=MockStarletteRequest + ) params = {"query": {"proj:epsg": {"gt": test_item["properties"]["proj:epsg"]}}} resp = app_client.post("/search", json=params) @@ -141,7 +153,9 @@ def test_app_query_extension_gt(load_test_data, app_client, postgres_transaction def test_app_query_extension_gte(load_test_data, app_client, postgres_transactions): test_item = load_test_data("test_item.json") - postgres_transactions.create_item(test_item, request=MockStarletteRequest) + postgres_transactions.create_item( + test_item["collection"], test_item, request=MockStarletteRequest + ) params = {"query": {"proj:epsg": {"gte": test_item["properties"]["proj:epsg"]}}} resp = app_client.post("/search", json=params) @@ -160,7 +174,9 @@ def test_app_query_extension_limit_lt0( load_test_data, app_client, postgres_transactions ): item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + item["collection"], item, request=MockStarletteRequest + ) params = {"limit": -1} resp = app_client.post("/search", json=params) @@ -171,7 +187,9 @@ def test_app_query_extension_limit_gt10000( load_test_data, app_client, postgres_transactions ): item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + item["collection"], item, request=MockStarletteRequest + ) params = {"limit": 10001} resp = app_client.post("/search", json=params) @@ -182,7 +200,9 @@ def test_app_query_extension_limit_10000( load_test_data, app_client, postgres_transactions ): item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + item["collection"], item, request=MockStarletteRequest + ) params = {"limit": 10000} resp = app_client.post("/search", json=params) @@ -194,7 +214,9 @@ def test_app_sort_extension(load_test_data, app_client, postgres_transactions): item_date = datetime.strptime( first_item["properties"]["datetime"], "%Y-%m-%dT%H:%M:%SZ" ) - postgres_transactions.create_item(first_item, request=MockStarletteRequest) + postgres_transactions.create_item( + first_item["collection"], first_item, request=MockStarletteRequest + ) second_item = load_test_data("test_item.json") second_item["id"] = "another-item" @@ -202,7 +224,9 @@ def test_app_sort_extension(load_test_data, app_client, postgres_transactions): second_item["properties"]["datetime"] = another_item_date.strftime( "%Y-%m-%dT%H:%M:%SZ" ) - postgres_transactions.create_item(second_item, request=MockStarletteRequest) + postgres_transactions.create_item( + second_item["collection"], second_item, request=MockStarletteRequest + ) params = { "collections": [first_item["collection"]], @@ -217,7 +241,9 @@ def test_app_sort_extension(load_test_data, app_client, postgres_transactions): def test_search_invalid_date(load_test_data, app_client, postgres_transactions): item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + item["collection"], item, request=MockStarletteRequest + ) params = { "datetime": "2020-XX-01/2020-10-30", @@ -230,7 +256,9 @@ def test_search_invalid_date(load_test_data, app_client, postgres_transactions): def test_search_point_intersects(load_test_data, app_client, postgres_transactions): item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + item["collection"], item, request=MockStarletteRequest + ) point = [150.04, -33.14] intersects = {"type": "Point", "coordinates": point} @@ -247,7 +275,9 @@ def test_search_point_intersects(load_test_data, app_client, postgres_transactio def test_datetime_non_interval(load_test_data, app_client, postgres_transactions): item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + item["collection"], item, request=MockStarletteRequest + ) alternate_formats = [ "2020-02-12T12:30:22+00:00", "2020-02-12T12:30:22.00Z", @@ -269,7 +299,9 @@ def test_datetime_non_interval(load_test_data, app_client, postgres_transactions def test_bbox_3d(load_test_data, app_client, postgres_transactions): item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + item["collection"], item, request=MockStarletteRequest + ) australia_bbox = [106.343365, -47.199523, 0.1, 168.218365, -19.437288, 0.1] params = { @@ -286,7 +318,9 @@ def test_search_line_string_intersects( load_test_data, app_client, postgres_transactions ): item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + item["collection"], item, request=MockStarletteRequest + ) line = [[150.04, -33.14], [150.22, -33.89]] intersects = {"type": "LineString", "coordinates": line} @@ -305,7 +339,9 @@ def test_app_fields_extension_return_all_properties( load_test_data, app_client, postgres_transactions ): item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + item["collection"], item, request=MockStarletteRequest + ) resp = app_client.get( "/search", params={"collections": ["test-collection"], "fields": "properties"} @@ -323,7 +359,9 @@ def test_app_fields_extension_return_all_properties( def test_landing_forwarded_header(load_test_data, app_client, postgres_transactions): item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + item["collection"], item, request=MockStarletteRequest + ) response = app_client.get( "/", @@ -341,7 +379,9 @@ def test_app_search_response_forwarded_header( load_test_data, app_client, postgres_transactions ): item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + item["collection"], item, request=MockStarletteRequest + ) resp = app_client.get( "/search", @@ -357,7 +397,9 @@ def test_app_search_response_x_forwarded_headers( load_test_data, app_client, postgres_transactions ): item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + item["collection"], item, request=MockStarletteRequest + ) resp = app_client.get( "/search", @@ -376,7 +418,9 @@ def test_app_search_response_duplicate_forwarded_headers( load_test_data, app_client, postgres_transactions ): item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + item["collection"], item, request=MockStarletteRequest + ) resp = app_client.get( "/search", diff --git a/stac_fastapi/sqlalchemy/tests/clients/test_postgres.py b/stac_fastapi/sqlalchemy/tests/clients/test_postgres.py index 143e003db..da69c78bb 100644 --- a/stac_fastapi/sqlalchemy/tests/clients/test_postgres.py +++ b/stac_fastapi/sqlalchemy/tests/clients/test_postgres.py @@ -96,7 +96,9 @@ def test_get_item( collection_data, request=MockStarletteRequest ) data = load_test_data("test_item.json") - postgres_transactions.create_item(data, request=MockStarletteRequest) + postgres_transactions.create_item( + collection_data["id"], data, request=MockStarletteRequest + ) coll = postgres_core.get_item( item_id=data["id"], collection_id=data["collection"], @@ -118,7 +120,9 @@ def test_get_collection_items( for _ in range(5): item["id"] = str(uuid.uuid4()) - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + coll["id"], item, request=MockStarletteRequest + ) fc = postgres_core.item_collection(coll["id"], request=MockStarletteRequest) assert len(fc["features"]) == 5 @@ -135,7 +139,7 @@ def test_create_item( coll = load_test_data("test_collection.json") postgres_transactions.create_collection(coll, request=MockStarletteRequest) item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item(coll["id"], item, request=MockStarletteRequest) resp = postgres_core.get_item( item["id"], item["collection"], request=MockStarletteRequest ) @@ -152,10 +156,12 @@ def test_create_item_already_exists( postgres_transactions.create_collection(coll, request=MockStarletteRequest) item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item(coll["id"], item, request=MockStarletteRequest) with pytest.raises(ConflictError): - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + coll["id"], item, request=MockStarletteRequest + ) def test_create_duplicate_item_different_collections( @@ -173,7 +179,9 @@ def test_create_duplicate_item_different_collections( # add item to test-collection item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + "test-collection", item, request=MockStarletteRequest + ) # get item from test-collection resp = postgres_core.get_item( @@ -185,7 +193,9 @@ def test_create_duplicate_item_different_collections( # add item to test-collection-2 item["collection"] = "test-collection-2" - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item( + "test-collection-2", item, request=MockStarletteRequest + ) # get item with same id from test-collection-2 resp = postgres_core.get_item( @@ -205,10 +215,12 @@ def test_update_item( postgres_transactions.create_collection(coll, request=MockStarletteRequest) item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item(coll["id"], item, request=MockStarletteRequest) item["properties"]["foo"] = "bar" - postgres_transactions.update_item(item, request=MockStarletteRequest) + postgres_transactions.update_item( + coll["id"], item["id"], item, request=MockStarletteRequest + ) updated_item = postgres_core.get_item( item["id"], item["collection"], request=MockStarletteRequest @@ -225,10 +237,12 @@ def test_update_geometry( postgres_transactions.create_collection(coll, request=MockStarletteRequest) item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item(coll["id"], item, request=MockStarletteRequest) item["geometry"]["coordinates"] = [[[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]]] - postgres_transactions.update_item(item, request=MockStarletteRequest) + postgres_transactions.update_item( + coll["id"], item["id"], item, request=MockStarletteRequest + ) updated_item = postgres_core.get_item( item["id"], item["collection"], request=MockStarletteRequest @@ -245,7 +259,7 @@ def test_delete_item( postgres_transactions.create_collection(coll, request=MockStarletteRequest) item = load_test_data("test_item.json") - postgres_transactions.create_item(item, request=MockStarletteRequest) + postgres_transactions.create_item(coll["id"], item, request=MockStarletteRequest) postgres_transactions.delete_item( item["id"], item["collection"], request=MockStarletteRequest @@ -330,7 +344,9 @@ def test_feature_collection_insert( feature_collection = {"type": "FeatureCollection", "features": features} - postgres_transactions.create_item(feature_collection, request=MockStarletteRequest) + postgres_transactions.create_item( + coll["id"], feature_collection, request=MockStarletteRequest + ) fc = postgres_core.item_collection(coll["id"], request=MockStarletteRequest) assert len(fc["features"]) >= 10 diff --git a/stac_fastapi/sqlalchemy/tests/resources/test_item.py b/stac_fastapi/sqlalchemy/tests/resources/test_item.py index d7618c2c1..e19ac7740 100644 --- a/stac_fastapi/sqlalchemy/tests/resources/test_item.py +++ b/stac_fastapi/sqlalchemy/tests/resources/test_item.py @@ -146,7 +146,8 @@ def test_update_item_duplicate(app_client, load_test_data): # update gsd in test_item, test-collection-2 test_item["properties"]["gsd"] = 16 resp = app_client.put( - f"/collections/{test_item['collection']}/items", json=test_item + f"/collections/{test_item['collection']}/items/{test_item['id']}", + json=test_item, ) assert resp.status_code == 200 updated_item = resp.json() @@ -156,7 +157,8 @@ def test_update_item_duplicate(app_client, load_test_data): test_item["collection"] = "test-collection" test_item["properties"]["gsd"] = 17 resp = app_client.put( - f"/collections/{test_item['collection']}/items", json=test_item + f"/collections/{test_item['collection']}/items/{test_item['id']}", + json=test_item, ) assert resp.status_code == 200 updated_item = resp.json() @@ -208,7 +210,8 @@ def test_update_item_already_exists(app_client, load_test_data): assert test_item["properties"]["gsd"] != 16 test_item["properties"]["gsd"] = 16 resp = app_client.put( - f"/collections/{test_item['collection']}/items", json=test_item + f"/collections/{test_item['collection']}/items/{test_item['id']}", + json=test_item, ) updated_item = resp.json() assert updated_item["properties"]["gsd"] == 16 @@ -218,7 +221,8 @@ def test_update_new_item(app_client, load_test_data): """Test updating an item which does not exist (transactions extension)""" test_item = load_test_data("test_item.json") resp = app_client.put( - f"/collections/{test_item['collection']}/items", json=test_item + f"/collections/{test_item['collection']}/items/{test_item['id']}", + json=test_item, ) assert resp.status_code == 404 @@ -236,7 +240,8 @@ def test_update_item_missing_collection(app_client, load_test_data): # Try to update collection of the item test_item["collection"] = "stac is cool" resp = app_client.put( - f"/collections/{test_item['collection']}/items", json=test_item + f"/collections/{test_item['collection']}/items/{test_item['id']}", + json=test_item, ) assert resp.status_code == 404 @@ -253,7 +258,8 @@ def test_update_item_geometry(app_client, load_test_data): # Update the geometry of the item test_item["geometry"]["coordinates"] = [[[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]]] resp = app_client.put( - f"/collections/{test_item['collection']}/items", json=test_item + f"/collections/{test_item['collection']}/items/{test_item['id']}", + json=test_item, ) assert resp.status_code == 200 @@ -366,7 +372,9 @@ def test_item_timestamps(app_client, load_test_data): time.sleep(2) # Confirm `updated` timestamp item["properties"]["proj:epsg"] = 4326 - resp = app_client.put(f"/collections/{test_item['collection']}/items", json=item) + resp = app_client.put( + f"/collections/{test_item['collection']}/items/{item['id']}", json=item + ) assert resp.status_code == 200 updated_item = resp.json() diff --git a/stac_fastapi/testdata/joplin/feature.geojson b/stac_fastapi/testdata/joplin/feature.geojson new file mode 100644 index 000000000..47db3190d --- /dev/null +++ b/stac_fastapi/testdata/joplin/feature.geojson @@ -0,0 +1,59 @@ +{ + "id": "f2cca2a3-288b-4518-8a3e-a4492bb60b08", + "type": "Feature", + "collection": "joplin", + "links": [], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -94.6884155, + 37.0595608 + ], + [ + -94.6884155, + 37.0332547 + ], + [ + -94.6554565, + 37.0332547 + ], + [ + -94.6554565, + 37.0595608 + ], + [ + -94.6884155, + 37.0595608 + ] + ] + ] + }, + "properties": { + "proj:epsg": 3857, + "orientation": "nadir", + "height": 2500, + "width": 2500, + "datetime": "2000-02-02T00:00:00Z", + "gsd": 0.5971642834779395 + }, + "assets": { + "COG": { + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C350000e4102500n.tif", + "title": "NOAA STORM COG" + } + }, + "bbox": [ + -94.6884155, + 37.0332547, + -94.6554565, + 37.0595608 + ], + "stac_extensions": [ + "https://stac-extensions.github.io/eo/v1.0.0/schema.json", + "https://stac-extensions.github.io/projection/v1.0.0/schema.json" + ], + "stac_version": "1.0.0" +} \ No newline at end of file diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index 965b5f26b..bce7ca2a2 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -28,7 +28,7 @@ class BaseTransactionsClient(abc.ABC): @abc.abstractmethod def create_item( - self, item: stac_types.Item, **kwargs + self, collection_id: str, item: stac_types.Item, **kwargs ) -> Optional[Union[stac_types.Item, Response]]: """Create a new item. @@ -46,7 +46,7 @@ def create_item( @abc.abstractmethod def update_item( - self, item: stac_types.Item, **kwargs + self, collection_id: str, item_id: str, item: stac_types.Item, **kwargs ) -> Optional[Union[stac_types.Item, Response]]: """Perform a complete update on an existing item. @@ -138,7 +138,7 @@ class AsyncBaseTransactionsClient(abc.ABC): @abc.abstractmethod async def create_item( - self, item: stac_types.Item, **kwargs + self, collection_id: str, item: stac_types.Item, **kwargs ) -> Optional[Union[stac_types.Item, Response]]: """Create a new item. @@ -155,7 +155,7 @@ async def create_item( @abc.abstractmethod async def update_item( - self, item: stac_types.Item, **kwargs + self, collection_id: str, item_id: str, item: stac_types.Item, **kwargs ) -> Optional[Union[stac_types.Item, Response]]: """Perform a complete update on an existing item. diff --git a/stac_fastapi/types/stac_fastapi/types/stac.py b/stac_fastapi/types/stac_fastapi/types/stac.py index a770de31b..ef61c2f32 100644 --- a/stac_fastapi/types/stac_fastapi/types/stac.py +++ b/stac_fastapi/types/stac_fastapi/types/stac.py @@ -1,5 +1,14 @@ """STAC types.""" -from typing import Any, Dict, List, Optional, TypedDict, Union +import sys +from typing import Any, Dict, List, Optional, Union + +# Avoids a Pydantic error: +# TypeError: You should use `typing_extensions.TypedDict` instead of `typing.TypedDict` with Python < 3.9.2. +# Without it, there is no way to differentiate required and optional fields when subclassed. +if sys.version_info < (3, 9, 2): + from typing_extensions import TypedDict +else: + from typing import TypedDict NumType = Union[float, int]