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..0f155ab8 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: @@ -30,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: . @@ -45,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 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..b5f3abe7 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,95 @@ 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, 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: + raise Exception( + "Patch must be a list of PatchOperations or a PartialItem." + ) + + 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, 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: + 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) + + 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..745d4230 100644 --- a/tests/resources/test_collection.py +++ b/tests/resources/test_collection.py @@ -111,6 +111,44 @@ 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"], + "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, ): 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: