Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
app:
container_name: stac-fastapi-pgstac
#container_name: stac-fastapi-pgstac
image: stac-utils/stac-fastapi-pgstac
build: .
environment:
Expand Down Expand Up @@ -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: .
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
83 changes: 81 additions & 2 deletions stac_fastapi/pgstac/transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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 PartialCollection."
)

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
Expand Down
38 changes: 38 additions & 0 deletions tests/resources/test_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
):
Expand Down
55 changes: 55 additions & 0 deletions tests/resources/test_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading