diff --git a/CHANGES.md b/CHANGES.md index 9c214108..98aa9574 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,6 +4,7 @@ ### Changed +* Add Item and Collection `PATCH` endpoints with support for [RFC 6902](https://tools.ietf.org/html/rfc6902) and [RFC 7396](https://tools.ietf.org/html/rfc7386) in the `TransactionExtension` - remove support of `cql-json` in Filter extension ([#840](https://github.com/stac-utils/stac-fastapi/pull/840)) ### Fixed @@ -175,7 +176,6 @@ ## [3.0.0] - 2024-07-29 Full changelog: https://stac-utils.github.io/stac-fastapi/migrations/v3.0.0/#changelog - **Changes since 3.0.0b3:** ### Changed diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py index 7db4d9a5..d4ebf951 100644 --- a/stac_fastapi/api/tests/test_api.py +++ b/stac_fastapi/api/tests/test_api.py @@ -3,10 +3,7 @@ from stac_fastapi.api.app import StacApi from stac_fastapi.api.models import ItemCollectionUri, create_request_model -from stac_fastapi.extensions.core import ( - TokenPaginationExtension, - TransactionExtension, -) +from stac_fastapi.extensions.core import TokenPaginationExtension, TransactionExtension from stac_fastapi.types import config, core @@ -430,6 +427,9 @@ def create_item(self, *args, **kwargs): def update_item(self, *args, **kwargs): return "dummy response" + def patch_item(self, *args, **kwargs): + return "dummy response" + def delete_item(self, *args, **kwargs): return "dummy response" @@ -439,6 +439,9 @@ def create_collection(self, *args, **kwargs): def update_collection(self, *args, **kwargs): return "dummy response" + def patch_collection(self, *args, **kwargs): + return "dummy response" + def delete_collection(self, *args, **kwargs): return "dummy response" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py index 2287bc8b..c0e390ae 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -5,6 +5,7 @@ import attr from fastapi import APIRouter, Body, FastAPI +from pydantic import TypeAdapter from stac_pydantic import Collection, Item, ItemCollection from stac_pydantic.shared import MimeTypes from starlette.responses import Response @@ -15,6 +16,7 @@ from stac_fastapi.types.config import ApiSettings from stac_fastapi.types.core import AsyncBaseTransactionsClient, BaseTransactionsClient from stac_fastapi.types.extension import ApiExtension +from stac_fastapi.types.stac import PartialCollection, PartialItem, PatchOperation class TransactionConformanceClasses(str, Enum): @@ -42,6 +44,16 @@ class PutItem(ItemUri): item: Annotated[Item, Body()] = attr.ib(default=None) +@attr.s +class PatchItem(ItemUri): + """Patch Item.""" + + patch: Annotated[ + Union[PartialItem, List[PatchOperation]], + Body(), + ] = attr.ib(default=None) + + @attr.s class PutCollection(CollectionUri): """Update Collection.""" @@ -49,6 +61,95 @@ class PutCollection(CollectionUri): collection: Annotated[Collection, Body()] = attr.ib(default=None) +@attr.s +class PatchCollection(CollectionUri): + """Patch Collection.""" + + patch: Annotated[ + Union[PartialCollection, List[PatchOperation]], + Body(), + ] = attr.ib(default=None) + + +_patch_item_schema = TypeAdapter(List[PatchOperation]).json_schema() | { + "examples": [ + [ + { + "op": "add", + "path": "/properties/foo", + "value": "bar", + }, + { + "op": "replace", + "path": "/properties/foo", + "value": "bar", + }, + { + "op": "test", + "path": "/properties/foo", + "value": "bar", + }, + { + "op": "copy", + "path": "/properties/foo", + "from": "/properties/bar", + }, + { + "op": "move", + "path": "/properties/foo", + "from": "/properties/bar", + }, + { + "op": "remove", + "path": "/properties/foo", + }, + ] + ] +} +# ref: https://github.com/pydantic/pydantic/issues/889 +_patch_item_schema["items"]["anyOf"] = list(_patch_item_schema["$defs"].values()) + +_patch_collection_schema = TypeAdapter(List[PatchOperation]).json_schema() | { + "examples": [ + [ + { + "op": "add", + "path": "/summeries/foo", + "value": "bar", + }, + { + "op": "replace", + "path": "/summeries/foo", + "value": "bar", + }, + { + "op": "test", + "path": "/summeries/foo", + "value": "bar", + }, + { + "op": "copy", + "path": "/summeries/foo", + "from": "/summeries/bar", + }, + { + "op": "move", + "path": "/summeries/foo", + "from": "/summeries/bar", + }, + { + "op": "remove", + "path": "/summeries/foo", + }, + ] + ] +} +# ref: https://github.com/pydantic/pydantic/issues/889 +_patch_collection_schema["items"]["anyOf"] = list( + _patch_collection_schema["$defs"].values() +) + + @attr.s class TransactionExtension(ApiExtension): """Transaction Extension. @@ -126,6 +227,47 @@ def register_update_item(self): endpoint=create_async_endpoint(self.client.update_item, PutItem), ) + def register_patch_item(self): + """Register patch item endpoint (PATCH + /collections/{collection_id}/items/{item_id}).""" + self.router.add_api_route( + name="Patch Item", + path="/collections/{collection_id}/items/{item_id}", + response_model=Item if self.settings.enable_response_models else None, + responses={ + 200: { + "content": { + MimeTypes.geojson.value: {}, + }, + "model": Item, + } + }, + openapi_extra={ + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": _patch_item_schema, + }, + "application/merge-patch+json": { + "schema": PartialItem.model_json_schema(), + }, + "application/json": { + "schema": PartialItem.model_json_schema(), + }, + }, + "required": True, + }, + }, + response_class=self.response_class, + response_model_exclude_unset=True, + response_model_exclude_none=True, + methods=["PATCH"], + endpoint=create_async_endpoint( + self.client.patch_item, + PatchItem, + ), + ) + def register_delete_item(self): """Register delete item endpoint (DELETE /collections/{collection_id}/items/{item_id}).""" @@ -148,11 +290,6 @@ def register_delete_item(self): endpoint=create_async_endpoint(self.client.delete_item, ItemUri), ) - def register_patch_item(self): - """Register patch item endpoint (PATCH - /collections/{collection_id}/items/{item_id}).""" - raise NotImplementedError - def register_create_collection(self): """Register create collection endpoint (POST /collections).""" self.router.add_api_route( @@ -196,6 +333,46 @@ def register_update_collection(self): endpoint=create_async_endpoint(self.client.update_collection, PutCollection), ) + def register_patch_collection(self): + """Register patch collection endpoint (PATCH /collections/{collection_id}).""" + self.router.add_api_route( + name="Patch Collection", + path="/collections/{collection_id}", + response_model=Collection if self.settings.enable_response_models else None, + responses={ + 200: { + "content": { + MimeTypes.geojson.value: {}, + }, + "model": Collection, + } + }, + openapi_extra={ + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": _patch_collection_schema, + }, + "application/merge-patch+json": { + "schema": PartialCollection.model_json_schema(), + }, + "application/json": { + "schema": PartialCollection.model_json_schema(), + }, + }, + "required": True, + }, + }, + response_class=self.response_class, + response_model_exclude_unset=True, + response_model_exclude_none=True, + methods=["PATCH"], + endpoint=create_async_endpoint( + self.client.patch_collection, + PatchCollection, + ), + ) + def register_delete_collection(self): """Register delete collection endpoint (DELETE /collections/{collection_id}).""" self.router.add_api_route( @@ -217,10 +394,6 @@ def register_delete_collection(self): endpoint=create_async_endpoint(self.client.delete_collection, CollectionUri), ) - def register_patch_collection(self): - """Register patch collection endpoint (PATCH /collections/{collection_id}).""" - raise NotImplementedError - def register(self, app: FastAPI) -> None: """Register the extension with a FastAPI application. @@ -233,8 +406,10 @@ def register(self, app: FastAPI) -> None: self.router.prefix = app.state.router_prefix self.register_create_item() self.register_update_item() + self.register_patch_item() self.register_delete_item() self.register_create_collection() self.register_update_collection() + self.register_patch_collection() self.register_delete_collection() app.include_router(self.router, tags=["Transaction Extension"]) diff --git a/stac_fastapi/extensions/tests/test_transaction.py b/stac_fastapi/extensions/tests/test_transaction.py index 689e519d..2f6487b7 100644 --- a/stac_fastapi/extensions/tests/test_transaction.py +++ b/stac_fastapi/extensions/tests/test_transaction.py @@ -1,5 +1,5 @@ import json -from typing import Iterator, Union +from typing import Iterator, List, Union import pytest from stac_pydantic import Collection @@ -11,6 +11,7 @@ from stac_fastapi.extensions.core import TransactionExtension from stac_fastapi.types.config import ApiSettings from stac_fastapi.types.core import BaseCoreClient, BaseTransactionsClient +from stac_fastapi.types.stac import PartialCollection, PartialItem, PatchOperation class DummyCoreClient(BaseCoreClient): @@ -46,6 +47,22 @@ def update_item(self, collection_id: str, item_id: str, item: Item, **kwargs): "type": item.type, } + def patch_item( + self, + collection_id: str, + item_id: str, + patch: Union[PartialItem, List[PatchOperation]], + **kwargs, + ): + if isinstance(patch, PartialItem): + patch = patch.operations() + + return { + "path_collection_id": collection_id, + "path_item_id": item_id, + "patch": patch, + } + def delete_item(self, item_id: str, collection_id: str, **kwargs): return { "path_collection_id": collection_id, @@ -58,6 +75,20 @@ def create_collection(self, collection: Collection, **kwargs): def update_collection(self, collection_id: str, collection: Collection, **kwargs): return {"path_collection_id": collection_id, "type": collection.type} + def patch_collection( + self, + collection_id: str, + patch: Union[PartialCollection, List[PatchOperation]], + **kwargs, + ): + if isinstance(patch, PartialCollection): + patch = patch.operations() + + return { + "path_collection_id": collection_id, + "patch": patch, + } + def delete_collection(self, collection_id: str, **kwargs): return {"path_collection_id": collection_id} @@ -88,6 +119,33 @@ def test_update_item(client: TestClient, item: Item) -> None: assert response.json()["type"] == "Feature" +def test_patch_operation_item(client: TestClient) -> None: + response = client.patch( + "/collections/a-collection/items/an-item", + content='[{"op": "add", "path": "/properties/foo", "value": "bar"}]', + ) + assert response.is_success, response.text + assert response.json()["path_collection_id"] == "a-collection" + assert response.json()["path_item_id"] == "an-item" + assert response.json()["patch"] == [ + {"op": "add", "path": "/properties/foo", "value": "bar"} + ] + + +def test_patch_merge_item(client: TestClient) -> None: + response = client.patch( + "/collections/a-collection/items/an-item", + content=json.dumps({"properties": {"hello": "world", "foo": None}}), + ) + assert response.is_success, response.text + assert response.json()["path_collection_id"] == "a-collection" + assert response.json()["path_item_id"] == "an-item" + assert response.json()["patch"] == [ + {"op": "add", "path": "/properties/hello", "value": "world"}, + {"op": "remove", "path": "/properties/foo"}, + ] + + def test_delete_item(client: TestClient) -> None: response = client.delete("/collections/a-collection/items/an-item") assert response.is_success, response.text @@ -108,6 +166,31 @@ def test_update_collection(client: TestClient, collection: Collection) -> None: assert response.json()["type"] == "Collection" +def test_patch_operation_collection(client: TestClient) -> None: + response = client.patch( + "/collections/a-collection", + content='[{"op": "add", "path": "/properties/foo", "value": "bar"}]', + ) + assert response.is_success, response.text + assert response.json()["path_collection_id"] == "a-collection" + assert response.json()["patch"] == [ + {"op": "add", "path": "/properties/foo", "value": "bar"} + ] + + +def test_patch_merge_collection(client: TestClient) -> None: + response = client.patch( + "/collections/a-collection", + content=json.dumps({"summaries": {"hello": "world", "foo": None}}), + ) + assert response.is_success, response.text + assert response.json()["path_collection_id"] == "a-collection" + assert response.json()["patch"] == [ + {"op": "add", "path": "/summaries/hello", "value": "world"}, + {"op": "remove", "path": "/summaries/foo"}, + ] + + def test_delete_collection(client: TestClient, collection: Collection) -> None: response = client.delete("/collections/a-collection") assert response.is_success, response.text diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index c3d84e95..7d5e1fd9 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -80,6 +80,37 @@ def update_item( """ ... + @abc.abstractmethod + def patch_item( + self, + collection_id: str, + item_id: str, + patch: Union[stac.PartialItem, List[stac.PatchOperation]], + **kwargs, + ) -> Optional[Union[stac.Item, Response]]: + """Update an item from a collection. + + Called with `PATCH /collections/{collection_id}/items/{item_id}` + + example: + # convert merge patch item to list of operations + if isinstance(patch, PartialItem): + patch = patch.operations() + + item = backend.patch_item(collection_id, item_id, patch) + + return item + + Args: + item_id: id of the item. + collection_id: id of the collection. + patch: either the partial item or list of patch operations. + + Returns: + The patched item. + """ + ... + @abc.abstractmethod def delete_item( self, item_id: str, collection_id: str, **kwargs @@ -133,6 +164,35 @@ def update_collection( """ ... + @abc.abstractmethod + def patch_collection( + self, + collection_id: str, + patch: Union[stac.PartialCollection, List[stac.PatchOperation]], + **kwargs, + ) -> Optional[Union[stac.Collection, Response]]: + """Update a collection. + + Called with `PATCH /collections/{collection_id}` + + example: + # convert merge patch item to list of operations + if isinstance(patch, PartialCollection): + patch = patch.operations() + + collection = backend.patch_collection(collection_id, patch) + + return collection + + Args: + collection_id: id of the collection. + patch: either the partial collection or list of patch operations. + + Returns: + The patched collection. + """ + ... + @abc.abstractmethod def delete_collection( self, collection_id: str, **kwargs @@ -193,6 +253,37 @@ async def update_item( """ ... + @abc.abstractmethod + async def patch_item( + self, + collection_id: str, + item_id: str, + patch: Union[stac.PartialItem, List[stac.PatchOperation]], + **kwargs, + ) -> Optional[Union[stac.Item, Response]]: + """Update an item from a collection. + + Called with `PATCH /collections/{collection_id}/items/{item_id}` + + example: + # convert merge patch item to list of operations + if isinstance(patch, PartialItem): + patch = patch.operations() + + item = backend.patch_item(collection_id, item_id, patch) + + return item + + Args: + item_id: id of the item. + collection_id: id of the collection. + patch: either the partial item or list of patch operations. + + Returns: + The patched item. + """ + ... + @abc.abstractmethod async def delete_item( self, item_id: str, collection_id: str, **kwargs @@ -246,6 +337,35 @@ async def update_collection( """ ... + @abc.abstractmethod + async def patch_collection( + self, + collection_id: str, + patch: Union[stac.PartialCollection, List[stac.PatchOperation]], + **kwargs, + ) -> Optional[Union[stac.Collection, Response]]: + """Update a collection. + + Called with `PATCH /collections/{collection_id}` + + example: + # convert merge patch item to list of operations + if isinstance(patch, PartialCollection): + patch = patch.operations() + + collection = backend.patch_collection(collection_id, patch) + + return collection + + Args: + collection_id: id of the collection. + patch: either the partial collection or list of patch operations. + + Returns: + The patched collection. + """ + ... + @abc.abstractmethod async def delete_collection( self, collection_id: str, **kwargs diff --git a/stac_fastapi/types/stac_fastapi/types/stac.py b/stac_fastapi/types/stac_fastapi/types/stac.py index 7cfdb3d6..dfd9a62b 100644 --- a/stac_fastapi/types/stac_fastapi/types/stac.py +++ b/stac_fastapi/types/stac_fastapi/types/stac.py @@ -1,8 +1,10 @@ """STAC types.""" -from typing import Any, Dict, List, Literal, Union +import json +from typing import Any, Dict, List, Literal, Optional, Union -from stac_pydantic.shared import BBox +from pydantic import ConfigDict, Field +from stac_pydantic.shared import BBox, StacBaseModel from typing_extensions import NotRequired, TypedDict NumType = Union[float, int] @@ -77,3 +79,152 @@ class Collections(TypedDict): links: List[Dict[str, Any]] numberMatched: NotRequired[int] numberReturned: NotRequired[int] + + +class PatchAddReplaceTest(StacBaseModel): + """Add, Replace or Test Operation.""" + + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + {"op": "add", "path": "/properties/foo", "value": "bar"}, + {"op": "replace", "path": "/properties/foo", "value": "bar"}, + {"op": "test", "path": "/properties/foo", "value": "bar"}, + ] + } + ) + + path: str + op: Literal["add", "replace", "test"] + value: Any + + @property + def json_value(self) -> str: + """JSON dump of value field. + + Returns: + str: JSON-ised value + """ + return json.dumps(self.value) + + +class PatchRemove(StacBaseModel): + """Remove Operation.""" + + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "op": "remove", + "path": "/properties/foo", + } + ] + } + ) + + path: str + op: Literal["remove"] + + +class PatchMoveCopy(StacBaseModel): + """Move or Copy Operation.""" + + model_config = ConfigDict( + populate_by_name=True, + json_schema_extra={ + "examples": [ + { + "op": "copy", + "path": "/properties/foo", + "from": "/properties/bar", + }, + { + "op": "move", + "path": "/properties/foo", + "from": "/properties/bar", + }, + ] + }, + ) + + path: str + op: Literal["move", "copy"] + from_: str = Field(alias="from") + + +PatchOperation = Union[PatchAddReplaceTest, PatchMoveCopy, PatchRemove] + + +class BasePartial(StacBaseModel): + """Base Partial Class.""" + + @staticmethod + def merge_to_operations(data: Dict) -> List[PatchOperation]: + """Convert merge operation to list of RF6902 operations. + + Args: + data: dictionary to convert. + + Returns: + List: list of RF6902 operations. + """ + operations = [] + + for key, value in data.copy().items(): + if value is None: + operations.append(PatchRemove(op="remove", path=f"/{key}")) + + elif isinstance(value, dict): + nested_operations = BasePartial.merge_to_operations(value) + + for nested_operation in nested_operations: + nested_operation.path = f"/{key}{nested_operation.path}" + operations.append(nested_operation) + + else: + operations.append( + PatchAddReplaceTest(op="add", path=f"/{key}", value=value) + ) + + return operations + + def operations(self) -> List[PatchOperation]: + """Equivalent RF6902 operations to merge of Partial. + + Returns: + List[PatchOperation]: Equivalent list of RF6902 operations + """ + return self.merge_to_operations(self.model_dump()) + + +class PartialCollection(BasePartial): + """Partial STAC Collection.""" + + type: Optional[str] = None + stac_version: Optional[str] = None + stac_extensions: Optional[List[str]] = None + id: Optional[str] = None + title: Optional[str] = None + description: Optional[str] = None + links: Optional[Dict[str, Any]] = None + keywords: Optional[List[str]] = None + license: Optional[str] = None + providers: Optional[List[Dict[str, Any]]] = None + extent: Optional[Dict[str, Any]] = None + summaries: Optional[Dict[str, Any]] = None + assets: Optional[Dict[str, Any]] = None + + +class PartialItem(BasePartial): + """Partial STAC Item.""" + + type: Optional[Literal["Feature"]] = None + stac_version: Optional[str] = None + stac_extensions: Optional[List[str]] = None + id: Optional[str] = None + geometry: Optional[Dict[str, Any]] = None + bbox: Optional[BBox] = None + properties: Optional[Dict[str, Any]] = None + links: Optional[List[Dict[str, Any]]] = None + assets: Optional[Dict[str, Any]] = None + collection: Optional[str] = None