From 6eaa6fd5c8162fe8f59b8fa86e744c2ae950f72e Mon Sep 17 00:00:00 2001 From: David W Bitner Date: Thu, 10 Jul 2025 13:26:17 -0500 Subject: [PATCH 1/6] add patch support --- setup.py | 2 + stac_fastapi/pgstac/transactions.py | 73 ++++++++++++++++++++++++++++- tests/resources/test_collection.py | 40 ++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 68ca0da8..07cc44cb 100644 --- a/setup.py +++ b/setup.py @@ -18,6 +18,8 @@ "cql2>=0.3.6", "pypgstac>=0.8,<0.10", "typing_extensions>=4.9.0", + "jsonpatch>=1.33.0", + "json-merge-patch>=0.3.0", ] extra_reqs = { diff --git a/stac_fastapi/pgstac/transactions.py b/stac_fastapi/pgstac/transactions.py index f4ed11c9..285da24e 100644 --- a/stac_fastapi/pgstac/transactions.py +++ b/stac_fastapi/pgstac/transactions.py @@ -5,8 +5,10 @@ from typing import List, Optional, Union import attr +import jsonpatch from buildpg import render from fastapi import HTTPException, Request +from json_merge_patch import merge from stac_fastapi.extensions.core.transaction import AsyncBaseTransactionsClient from stac_fastapi.extensions.core.transaction.request import ( PartialCollection, @@ -19,6 +21,7 @@ Items, ) from stac_fastapi.types import stac as stac_types +from stac_fastapi.types.errors import NotFoundError from stac_pydantic import Collection, Item, ItemCollection from starlette.responses import JSONResponse, Response @@ -219,19 +222,85 @@ async def patch_item( collection_id: str, item_id: str, patch: Union[PartialItem, List[PatchOperation]], + request: Request, **kwargs, ) -> Optional[Union[stac_types.Item, Response]]: """Patch Item.""" - raise NotImplementedError + + # Get Existing Item to Patch + async with request.app.state.get_connection(request, "r") as conn: + q, p = render( + """ + SELECT * FROM get_item(:item_id::text, :collection_id::text); + """, + item_id=item_id, + collection_id=collection_id, + ) + existing = await conn.fetchval(q, *p) + if existing is None: + raise NotFoundError( + f"Item {item_id} does not exist in collection {collection_id}." + ) + + # Merge Patch with Existing Item + if isinstance(patch, PartialItem): + partial = patch.model_dump(mode="json") + item = merge(existing, partial) + else: + patch = jsonpatch.JsonPatch(patch) + item = patch.apply(existing) + + self._validate_item(request, item, collection_id, item_id) + item["collection"] = collection_id + + async with request.app.state.get_connection(request, "w") as conn: + await dbfunc(conn, "update_item", item) + + item["links"] = await ItemLinks( + collection_id=collection_id, + item_id=item["id"], + request=request, + ).get_links(extra_links=item.get("links")) + + return stac_types.Item(**item) async def patch_collection( self, collection_id: str, patch: Union[PartialCollection, List[PatchOperation]], + request: Request, **kwargs, ) -> Optional[Union[stac_types.Collection, Response]]: """Patch Collection.""" - raise NotImplementedError + + # Get Existing Collection to Patch + async with request.app.state.get_connection(request, "r") as conn: + q, p = render( + """ + SELECT * FROM get_collection(:id::text); + """, + id=collection_id, + ) + existing = await conn.fetchval(q, *p) + if existing is None: + raise NotFoundError(f"Collection {collection_id} does not exist.") + + # Merge Patch with Existing Collection + if isinstance(patch, PartialCollection): + partial = patch.model_dump(mode="json") + col = merge(existing, partial) + else: + patch = jsonpatch.JsonPatch(patch) + col = patch.apply(existing) + + async with request.app.state.get_connection(request, "w") as conn: + await dbfunc(conn, "update_collection", col) + + col["links"] = await CollectionLinks( + collection_id=col["id"], request=request + ).get_links(extra_links=col.get("links")) + + return stac_types.Collection(**col) @attr.s diff --git a/tests/resources/test_collection.py b/tests/resources/test_collection.py index 92c5943b..8cc08d88 100644 --- a/tests/resources/test_collection.py +++ b/tests/resources/test_collection.py @@ -111,6 +111,46 @@ async def test_update_new_collection(app_client, load_test_collection): assert resp.status_code == 404 +async def test_patch_collection_partialcollection( + app_client, load_test_collection: Collection +): + """Test patching a collection with a PartialCollection.""" + partial = { + "id": load_test_collection.id, + "collection": load_test_collection.collection, + "description": "Patched description", + } + + resp = await app_client.patch(f"/collections/{partial['id']}", json=partial) + assert resp.status_code == 200 + + resp = await app_client.get(f"/collections/{partial['id']}") + assert resp.status_code == 200 + + get_coll = Collection.model_validate(resp.json()) + + assert get_coll.description == "Patched description" + + +async def test_patch_collection_operations(app_client, load_test_collection: Collection): + """Test patching a collection with PatchOperations .""" + operations = [ + {"op": "replace", "path": "/description", "value": "Patched description"} + ] + + resp = await app_client.patch( + f"/collections/{load_test_collection.id}", json=operations + ) + assert resp.status_code == 200 + + resp = await app_client.get(f"/collections/{load_test_collection.id}") + assert resp.status_code == 200 + + get_coll = Collection.model_validate(resp.json()) + + assert get_coll.description == "Patched description" + + async def test_nocollections( app_client, ): From cda1f8f81caa25782bc05fc9fd3171518c9893c4 Mon Sep 17 00:00:00 2001 From: David W Bitner Date: Thu, 10 Jul 2025 15:08:38 -0500 Subject: [PATCH 2/6] update tests --- CHANGES.md | 1 + Makefile | 2 +- docker-compose.yml | 6 ++-- stac_fastapi/pgstac/transactions.py | 22 ++++++++---- tests/resources/test_collection.py | 8 ++--- tests/resources/test_item.py | 55 +++++++++++++++++++++++++++++ 6 files changed, 79 insertions(+), 15 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index b838437b..031dc5c1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -44,6 +44,7 @@ - add `write_connection_pool` option in `stac_fastapi.pgstac.db.connect_to_db` function - add `write_postgres_settings` option in `stac_fastapi.pgstac.db.connect_to_db` function to set specific settings for the `writer` DB connection pool - add specific error message when trying to create `Item` with null geometry (not supported by PgSTAC) +- add support for Patch in transactions extension ### removed diff --git a/Makefile b/Makefile index 57b56b6d..83a24516 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,7 @@ docker-shell: .PHONY: test test: - $(runtests) /bin/bash -c 'export && python -m pytest /app/tests/api/test_api.py --log-cli-level $(LOG_LEVEL)' + $(runtests) /bin/bash -c 'export && python -m pytest /app/tests/ --log-cli-level $(LOG_LEVEL)' .PHONY: run-database run-database: diff --git a/docker-compose.yml b/docker-compose.yml index e1ceb6e2..81d12444 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: app: - container_name: stac-fastapi-pgstac + #container_name: stac-fastapi-pgstac image: stac-utils/stac-fastapi-pgstac build: . environment: @@ -30,7 +30,7 @@ services: command: bash -c "./scripts/wait-for-it.sh database:5432 && python -m stac_fastapi.pgstac.app" tests: - container_name: stac-fastapi-pgstac-test + #container_name: stac-fastapi-pgstac-test image: stac-utils/stac-fastapi-pgstac-test build: context: . @@ -45,7 +45,7 @@ services: command: bash -c "python -m pytest -s -vv" database: - container_name: stac-db + #container_name: stac-db image: ghcr.io/stac-utils/pgstac:v0.9.2 environment: - POSTGRES_USER=username diff --git a/stac_fastapi/pgstac/transactions.py b/stac_fastapi/pgstac/transactions.py index 285da24e..0c19d9de 100644 --- a/stac_fastapi/pgstac/transactions.py +++ b/stac_fastapi/pgstac/transactions.py @@ -243,12 +243,17 @@ async def patch_item( ) # Merge Patch with Existing Item - if isinstance(patch, PartialItem): + if isinstance(patch, list): + patchjson = [op.model_dump(mode="json") for op in patch] + p = jsonpatch.JsonPatch(patchjson) + item = p.apply(existing) + elif isinstance(patch, PartialItem): partial = patch.model_dump(mode="json") item = merge(existing, partial) else: - patch = jsonpatch.JsonPatch(patch) - item = patch.apply(existing) + raise Exception( + "Patch must be a list of PatchOperations or a PartialCollection." + ) self._validate_item(request, item, collection_id, item_id) item["collection"] = collection_id @@ -286,12 +291,17 @@ async def patch_collection( raise NotFoundError(f"Collection {collection_id} does not exist.") # Merge Patch with Existing Collection - if isinstance(patch, PartialCollection): + if isinstance(patch, list): + patchjson = [op.model_dump(mode="json") for op in patch] + p = jsonpatch.JsonPatch(patchjson) + col = p.apply(existing) + elif isinstance(patch, PartialCollection): partial = patch.model_dump(mode="json") col = merge(existing, partial) else: - patch = jsonpatch.JsonPatch(patch) - col = patch.apply(existing) + raise Exception( + "Patch must be a list of PatchOperations or a PartialCollection." + ) async with request.app.state.get_connection(request, "w") as conn: await dbfunc(conn, "update_collection", col) diff --git a/tests/resources/test_collection.py b/tests/resources/test_collection.py index 8cc08d88..745d4230 100644 --- a/tests/resources/test_collection.py +++ b/tests/resources/test_collection.py @@ -116,8 +116,7 @@ async def test_patch_collection_partialcollection( ): """Test patching a collection with a PartialCollection.""" partial = { - "id": load_test_collection.id, - "collection": load_test_collection.collection, + "id": load_test_collection["id"], "description": "Patched description", } @@ -139,15 +138,14 @@ async def test_patch_collection_operations(app_client, load_test_collection: Col ] resp = await app_client.patch( - f"/collections/{load_test_collection.id}", json=operations + f"/collections/{load_test_collection['id']}", json=operations ) assert resp.status_code == 200 - resp = await app_client.get(f"/collections/{load_test_collection.id}") + resp = await app_client.get(f"/collections/{load_test_collection['id']}") assert resp.status_code == 200 get_coll = Collection.model_validate(resp.json()) - assert get_coll.description == "Patched description" diff --git a/tests/resources/test_item.py b/tests/resources/test_item.py index a97077fb..4ea70193 100644 --- a/tests/resources/test_item.py +++ b/tests/resources/test_item.py @@ -188,6 +188,61 @@ async def test_update_item( assert post_self_link["href"] == get_self_link["href"] +async def test_patch_item_partialitem( + app_client, + load_test_collection: Collection, + load_test_item: Item, +): + """Test patching an Item with a PartialCollection.""" + item_id = load_test_item["id"] + collection_id = load_test_item["collection"] + assert collection_id == load_test_collection["id"] + partial = { + "id": item_id, + "collection": collection_id, + "properties": {"gsd": 10}, + } + + resp = await app_client.patch( + f"/collections/{collection_id}/items/{item_id}", json=partial + ) + assert resp.status_code == 200 + + resp = await app_client.get(f"/collections/{collection_id}/items/{item_id}") + assert resp.status_code == 200 + + get_item_json = resp.json() + Item.model_validate(get_item_json) + + assert get_item_json["properties"]["gsd"] == 10 + + +async def test_patch_item_operations( + app_client, + load_test_collection: Collection, + load_test_item: Item, +): + """Test patching an Item with PatchOperations .""" + + item_id = load_test_item["id"] + collection_id = load_test_item["collection"] + assert collection_id == load_test_collection["id"] + operations = [{"op": "replace", "path": "/properties/gsd", "value": 20}] + + resp = await app_client.patch( + f"/collections/{collection_id}/items/{item_id}", json=operations + ) + assert resp.status_code == 200 + + resp = await app_client.get(f"/collections/{collection_id}/items/{item_id}") + assert resp.status_code == 200 + + get_item_json = resp.json() + Item.model_validate(get_item_json) + + assert get_item_json["properties"]["gsd"] == 20 + + async def test_update_item_mismatched_collection_id( app_client, load_test_data: Callable, load_test_collection, load_test_item ) -> None: From a68565f420d022a3e5c42a0c696e61339fe09b16 Mon Sep 17 00:00:00 2001 From: David Bitner Date: Tue, 22 Jul 2025 12:44:43 -0500 Subject: [PATCH 3/6] Update docker-compose.yml Co-authored-by: Pete Gadomski --- docker-compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 81d12444..a1009685 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,5 @@ services: app: - #container_name: stac-fastapi-pgstac image: stac-utils/stac-fastapi-pgstac build: . environment: From 6c2e8612a381ccc477f820c06d1830a37b7fdeb6 Mon Sep 17 00:00:00 2001 From: David Bitner Date: Tue, 22 Jul 2025 12:44:51 -0500 Subject: [PATCH 4/6] Update docker-compose.yml Co-authored-by: Pete Gadomski --- docker-compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index a1009685..27508008 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,7 +29,6 @@ services: command: bash -c "./scripts/wait-for-it.sh database:5432 && python -m stac_fastapi.pgstac.app" tests: - #container_name: stac-fastapi-pgstac-test image: stac-utils/stac-fastapi-pgstac-test build: context: . From 5e388e55fa22bdae371e7f29856c87030b124407 Mon Sep 17 00:00:00 2001 From: David Bitner Date: Tue, 22 Jul 2025 12:45:04 -0500 Subject: [PATCH 5/6] Update stac_fastapi/pgstac/transactions.py Co-authored-by: Pete Gadomski --- stac_fastapi/pgstac/transactions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stac_fastapi/pgstac/transactions.py b/stac_fastapi/pgstac/transactions.py index 0c19d9de..b5f3abe7 100644 --- a/stac_fastapi/pgstac/transactions.py +++ b/stac_fastapi/pgstac/transactions.py @@ -252,7 +252,7 @@ async def patch_item( item = merge(existing, partial) else: raise Exception( - "Patch must be a list of PatchOperations or a PartialCollection." + "Patch must be a list of PatchOperations or a PartialItem." ) self._validate_item(request, item, collection_id, item_id) From 4ada66723e9c011b69796604453ddcb8947cf55b Mon Sep 17 00:00:00 2001 From: David Bitner Date: Tue, 22 Jul 2025 12:45:16 -0500 Subject: [PATCH 6/6] Update docker-compose.yml Co-authored-by: Pete Gadomski --- docker-compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 27508008..0f155ab8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,7 +43,6 @@ services: command: bash -c "python -m pytest -s -vv" database: - #container_name: stac-db image: ghcr.io/stac-utils/pgstac:v0.9.2 environment: - POSTGRES_USER=username