Skip to content

Commit d43ea54

Browse files
committed
2 parents ef8ac1c + 57f2fde commit d43ea54

File tree

7 files changed

+176
-6
lines changed

7 files changed

+176
-6
lines changed

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
- add `write_connection_pool` option in `stac_fastapi.pgstac.db.connect_to_db` function
4949
- add `write_postgres_settings` option in `stac_fastapi.pgstac.db.connect_to_db` function to set specific settings for the `writer` DB connection pool
5050
- add specific error message when trying to create `Item` with null geometry (not supported by PgSTAC)
51+
- add support for Patch in transactions extension
5152

5253
### removed
5354

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ docker-shell:
3030

3131
.PHONY: test
3232
test:
33-
$(runtests) /bin/bash -c 'export && python -m pytest /app/tests/api/test_api.py --log-cli-level $(LOG_LEVEL)'
33+
$(runtests) /bin/bash -c 'export && python -m pytest /app/tests/ --log-cli-level $(LOG_LEVEL)'
3434

3535
.PHONY: run-database
3636
run-database:

docker-compose.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
services:
22
app:
3-
container_name: stac-fastapi-pgstac
43
image: stac-utils/stac-fastapi-pgstac
54
build: .
65
environment:
@@ -30,7 +29,6 @@ services:
3029
command: bash -c "./scripts/wait-for-it.sh database:5432 && python -m stac_fastapi.pgstac.app"
3130

3231
tests:
33-
container_name: stac-fastapi-pgstac-test
3432
image: stac-utils/stac-fastapi-pgstac-test
3533
build:
3634
context: .
@@ -45,7 +43,6 @@ services:
4543
command: bash -c "python -m pytest -s -vv"
4644

4745
database:
48-
container_name: stac-db
4946
image: ghcr.io/stac-utils/pgstac:v0.9.2
5047
environment:
5148
- POSTGRES_USER=username

setup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
"cql2>=0.3.6",
1919
"pypgstac>=0.8,<0.10",
2020
"typing_extensions>=4.9.0",
21+
"jsonpatch>=1.33.0",
22+
"json-merge-patch>=0.3.0",
2123
]
2224

2325
extra_reqs = {

stac_fastapi/pgstac/transactions.py

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
from typing import List, Optional, Union
66

77
import attr
8+
import jsonpatch
89
from buildpg import render
910
from fastapi import HTTPException, Request
11+
from json_merge_patch import merge
1012
from stac_fastapi.extensions.core.transaction import AsyncBaseTransactionsClient
1113
from stac_fastapi.extensions.core.transaction.request import (
1214
PartialCollection,
@@ -19,6 +21,7 @@
1921
Items,
2022
)
2123
from stac_fastapi.types import stac as stac_types
24+
from stac_fastapi.types.errors import NotFoundError
2225
from stac_pydantic import Collection, Item, ItemCollection
2326
from starlette.responses import JSONResponse, Response
2427

@@ -219,19 +222,93 @@ async def patch_item(
219222
collection_id: str,
220223
item_id: str,
221224
patch: Union[PartialItem, List[PatchOperation]],
225+
request: Request,
222226
**kwargs,
223227
) -> Optional[Union[stac_types.Item, Response]]:
224228
"""Patch Item."""
225-
raise NotImplementedError
229+
230+
# Get Existing Item to Patch
231+
async with request.app.state.get_connection(request, "r") as conn:
232+
q, p = render(
233+
"""
234+
SELECT * FROM get_item(:item_id::text, :collection_id::text);
235+
""",
236+
item_id=item_id,
237+
collection_id=collection_id,
238+
)
239+
existing = await conn.fetchval(q, *p)
240+
if existing is None:
241+
raise NotFoundError(
242+
f"Item {item_id} does not exist in collection {collection_id}."
243+
)
244+
245+
# Merge Patch with Existing Item
246+
if isinstance(patch, list):
247+
patchjson = [op.model_dump(mode="json") for op in patch]
248+
p = jsonpatch.JsonPatch(patchjson)
249+
item = p.apply(existing)
250+
elif isinstance(patch, PartialItem):
251+
partial = patch.model_dump(mode="json")
252+
item = merge(existing, partial)
253+
else:
254+
raise Exception("Patch must be a list of PatchOperations or a PartialItem.")
255+
256+
self._validate_item(request, item, collection_id, item_id)
257+
item["collection"] = collection_id
258+
259+
async with request.app.state.get_connection(request, "w") as conn:
260+
await dbfunc(conn, "update_item", item)
261+
262+
item["links"] = await ItemLinks(
263+
collection_id=collection_id,
264+
item_id=item["id"],
265+
request=request,
266+
).get_links(extra_links=item.get("links"))
267+
268+
return stac_types.Item(**item)
226269

227270
async def patch_collection(
228271
self,
229272
collection_id: str,
230273
patch: Union[PartialCollection, List[PatchOperation]],
274+
request: Request,
231275
**kwargs,
232276
) -> Optional[Union[stac_types.Collection, Response]]:
233277
"""Patch Collection."""
234-
raise NotImplementedError
278+
279+
# Get Existing Collection to Patch
280+
async with request.app.state.get_connection(request, "r") as conn:
281+
q, p = render(
282+
"""
283+
SELECT * FROM get_collection(:id::text);
284+
""",
285+
id=collection_id,
286+
)
287+
existing = await conn.fetchval(q, *p)
288+
if existing is None:
289+
raise NotFoundError(f"Collection {collection_id} does not exist.")
290+
291+
# Merge Patch with Existing Collection
292+
if isinstance(patch, list):
293+
patchjson = [op.model_dump(mode="json") for op in patch]
294+
p = jsonpatch.JsonPatch(patchjson)
295+
col = p.apply(existing)
296+
elif isinstance(patch, PartialCollection):
297+
partial = patch.model_dump(mode="json")
298+
col = merge(existing, partial)
299+
else:
300+
raise Exception(
301+
"Patch must be a list of PatchOperations or a PartialCollection."
302+
)
303+
304+
async with request.app.state.get_connection(request, "w") as conn:
305+
await dbfunc(conn, "update_collection", col)
306+
307+
col["links"] = await CollectionLinks(
308+
collection_id=col["id"], request=request
309+
).get_links(extra_links=col.get("links"))
310+
311+
return stac_types.Collection(**col)
235312

236313

237314
@attr.s

tests/resources/test_collection.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,44 @@ async def test_update_new_collection(app_client, load_test_collection):
111111
assert resp.status_code == 404
112112

113113

114+
async def test_patch_collection_partialcollection(
115+
app_client, load_test_collection: Collection
116+
):
117+
"""Test patching a collection with a PartialCollection."""
118+
partial = {
119+
"id": load_test_collection["id"],
120+
"description": "Patched description",
121+
}
122+
123+
resp = await app_client.patch(f"/collections/{partial['id']}", json=partial)
124+
assert resp.status_code == 200
125+
126+
resp = await app_client.get(f"/collections/{partial['id']}")
127+
assert resp.status_code == 200
128+
129+
get_coll = Collection.model_validate(resp.json())
130+
131+
assert get_coll.description == "Patched description"
132+
133+
134+
async def test_patch_collection_operations(app_client, load_test_collection: Collection):
135+
"""Test patching a collection with PatchOperations ."""
136+
operations = [
137+
{"op": "replace", "path": "/description", "value": "Patched description"}
138+
]
139+
140+
resp = await app_client.patch(
141+
f"/collections/{load_test_collection['id']}", json=operations
142+
)
143+
assert resp.status_code == 200
144+
145+
resp = await app_client.get(f"/collections/{load_test_collection['id']}")
146+
assert resp.status_code == 200
147+
148+
get_coll = Collection.model_validate(resp.json())
149+
assert get_coll.description == "Patched description"
150+
151+
114152
async def test_nocollections(
115153
app_client,
116154
):

tests/resources/test_item.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,61 @@ async def test_update_item(
188188
assert post_self_link["href"] == get_self_link["href"]
189189

190190

191+
async def test_patch_item_partialitem(
192+
app_client,
193+
load_test_collection: Collection,
194+
load_test_item: Item,
195+
):
196+
"""Test patching an Item with a PartialCollection."""
197+
item_id = load_test_item["id"]
198+
collection_id = load_test_item["collection"]
199+
assert collection_id == load_test_collection["id"]
200+
partial = {
201+
"id": item_id,
202+
"collection": collection_id,
203+
"properties": {"gsd": 10},
204+
}
205+
206+
resp = await app_client.patch(
207+
f"/collections/{collection_id}/items/{item_id}", json=partial
208+
)
209+
assert resp.status_code == 200
210+
211+
resp = await app_client.get(f"/collections/{collection_id}/items/{item_id}")
212+
assert resp.status_code == 200
213+
214+
get_item_json = resp.json()
215+
Item.model_validate(get_item_json)
216+
217+
assert get_item_json["properties"]["gsd"] == 10
218+
219+
220+
async def test_patch_item_operations(
221+
app_client,
222+
load_test_collection: Collection,
223+
load_test_item: Item,
224+
):
225+
"""Test patching an Item with PatchOperations ."""
226+
227+
item_id = load_test_item["id"]
228+
collection_id = load_test_item["collection"]
229+
assert collection_id == load_test_collection["id"]
230+
operations = [{"op": "replace", "path": "/properties/gsd", "value": 20}]
231+
232+
resp = await app_client.patch(
233+
f"/collections/{collection_id}/items/{item_id}", json=operations
234+
)
235+
assert resp.status_code == 200
236+
237+
resp = await app_client.get(f"/collections/{collection_id}/items/{item_id}")
238+
assert resp.status_code == 200
239+
240+
get_item_json = resp.json()
241+
Item.model_validate(get_item_json)
242+
243+
assert get_item_json["properties"]["gsd"] == 20
244+
245+
191246
async def test_update_item_mismatched_collection_id(
192247
app_client, load_test_data: Callable, load_test_collection, load_test_item
193248
) -> None:

0 commit comments

Comments
 (0)