diff --git a/CHANGES.md b/CHANGES.md index bf70ff60..17a0443f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## [Unreleased] +* 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) + ### Added * Add `from_extensions()` method to `CollectionSearchExtension` and `CollectionSearchPostExtension` extensions to build the class based on a list of available extensions. @@ -14,8 +16,7 @@ ## [3.0.0] - 2024-07-29 -Full changelog: https://stac-utils.github.io/stac-fastapi/migrations/v3.0.0/#changelog - +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..dc37ccdc 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,12 @@ def create_item(self, *args, **kwargs): def update_item(self, *args, **kwargs): return "dummy response" + def json_patch_item(self, *args, **kwargs): + return "dummy response" + + def merge_patch_item(self, *args, **kwargs): + return "dummy response" + def delete_item(self, *args, **kwargs): return "dummy response" @@ -439,6 +442,12 @@ def create_collection(self, *args, **kwargs): def update_collection(self, *args, **kwargs): return "dummy response" + def merge_patch_collection(self, *args, **kwargs): + return "dummy response" + + def json_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 4e940a0e..c0233d36 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -1,9 +1,9 @@ """Transaction extension.""" -from typing import List, Optional, Type, Union +from typing import List, Literal, Optional, Type, Union import attr -from fastapi import APIRouter, Body, FastAPI +from fastapi import APIRouter, Body, FastAPI, Header from stac_pydantic import Collection, Item, ItemCollection from stac_pydantic.shared import MimeTypes from starlette.responses import JSONResponse, Response @@ -14,6 +14,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 @attr.s @@ -30,6 +31,26 @@ 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) + content_type: Annotated[ + Optional[ + Literal[ + "application/json-patch+json", + "application/merge-patch+json", + "application/json", + ] + ], + Header(), + ] = attr.ib(default="application/json") + + @attr.s class PutCollection(CollectionUri): """Update Collection.""" @@ -37,6 +58,26 @@ 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) + content_type: Annotated[ + Optional[ + Literal[ + "application/json-patch+json", + "application/merge-patch+json", + "application/json", + ] + ], + Header(), + ] = attr.ib(default="application/json") + + @attr.s class TransactionExtension(ApiExtension): """Transaction Extension. @@ -114,6 +155,31 @@ 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, + } + }, + 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}).""" @@ -136,11 +202,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( @@ -184,6 +245,30 @@ 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, + } + }, + 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( @@ -205,10 +290,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. @@ -221,8 +302,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..afac5735 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 Dict, 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 PatchOperation class DummyCoreClient(BaseCoreClient): @@ -46,6 +47,32 @@ def update_item(self, collection_id: str, item_id: str, item: Item, **kwargs): "type": item.type, } + def merge_patch_item( + self, + collection_id: str, + item_id: str, + item: Dict, + **kwargs, + ): + return { + "path_collection_id": collection_id, + "path_item_id": item_id, + "type": item["type"], + } + + def json_patch_item( + self, + collection_id: str, + item_id: str, + operations: List[PatchOperation], + **kwargs, + ): + return { + "path_collection_id": collection_id, + "path_item_id": item_id, + "first_op_type": operations[0].op, + } + def delete_item(self, item_id: str, collection_id: str, **kwargs): return { "path_collection_id": collection_id, @@ -58,6 +85,25 @@ 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 merge_patch_collection( + self, + collection_id: str, + collection: Dict, + **kwargs, + ): + return {"path_collection_id": collection_id, "type": collection["type"]} + + def json_patch_collection( + self, + collection_id: str, + operations: List[PatchOperation], + **kwargs, + ): + return { + "path_collection_id": collection_id, + "first_op_type": operations[0].op, + } + def delete_collection(self, collection_id: str, **kwargs): return {"path_collection_id": collection_id} @@ -88,6 +134,30 @@ def test_update_item(client: TestClient, item: Item) -> None: assert response.json()["type"] == "Feature" +def test_merge_patch_item(client: TestClient, item: Item) -> None: + response = client.patch( + "/collections/a-collection/items/an-item", content=json.dumps(item) + ) + 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()["type"] == "Feature" + + +def test_json_patch_item(client: TestClient) -> None: + operations = [{"op": "add", "path": "properties.new_prop", "value": "new_prop_value"}] + headers = {"Content-Type": "application/json-patch+json"} + response = client.patch( + "/collections/a-collection/items/an-item", + headers=headers, + content=json.dumps(operations), + ) + 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()["first_op_type"] == "add" + + def test_delete_item(client: TestClient) -> None: response = client.delete("/collections/a-collection/items/an-item") assert response.is_success, response.text @@ -108,6 +178,29 @@ def test_update_collection(client: TestClient, collection: Collection) -> None: assert response.json()["type"] == "Collection" +def test_merge_patch_collection(client: TestClient, collection: Collection) -> None: + response = client.patch( + "/collections/a-collection", + content=json.dumps(collection), + ) + assert response.is_success, response.text + assert response.json()["path_collection_id"] == "a-collection" + assert response.json()["type"] == "Collection" + + +def test_json_patch_collection(client: TestClient) -> None: + operations = [{"op": "add", "path": "summaries.new_prop", "value": "new_prop_value"}] + headers = {"Content-Type": "application/json-patch+json"} + response = client.patch( + "/collections/a-collection/items/an-item", + headers=headers, + content=json.dumps(operations), + ) + assert response.is_success, response.text + assert response.json()["path_collection_id"] == "a-collection" + assert response.json()["first_op_type"] == "add" + + 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 18381b7c..a83a7e12 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -1,12 +1,13 @@ """Base clients.""" import abc -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Literal, Optional, Union from urllib.parse import urljoin import attr from fastapi import Request from geojson_pydantic.geometries import Geometry +from pydantic import TypeAdapter from stac_pydantic import Collection, Item, ItemCollection from stac_pydantic.api.version import STAC_API_VERSION from stac_pydantic.links import Relations @@ -81,6 +82,101 @@ def update_item( """ ... + def patch_item( + self, + collection_id: str, + item_id: str, + patch: Union[stac.PartialItem, List[stac.PatchOperation]], + content_type: Optional[ + Literal[ + "application/json-patch+json", + "application/merge-patch+json", + "application/json", + ] + ] = "application/json", + **kwargs, + ) -> Optional[Union[stac.Item, Response]]: + """Update an item from a collection. + + Called with `PATCH /collections/{collection_id}/items/{item_id}` + + 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. + """ + if isinstance(patch, list) and content_type == "application/json-patch+json": + return self.json_patch_item( + collection_id, + item_id, + patch, + **kwargs, + ) + + elif isinstance(patch, dict) and content_type in [ + "application/merge-patch+json", + "application/json", + ]: + partialItemValidator = TypeAdapter(stac.PartialItem) + + patch = partialItemValidator.validate_python(patch) + return self.merge_patch_item( + collection_id, + item_id, + patch, + **kwargs, + ) + + else: + raise NotImplementedError("Content-Type and body combination not implemented") + + @abc.abstractmethod + def merge_patch_item( + self, + collection_id: str, + item_id: str, + item: stac.PartialItem, + **kwargs, + ) -> Optional[Union[stac.Item, Response]]: + """Update an item from a collection. + + Called with `PATCH /collections/{collection_id}/items/{item_id}` + + Args: + item_id: id of the item. + collection_id: id of the collection. + item: the partial item. + + Returns: + The patched item. + """ + ... + + @abc.abstractmethod + def json_patch_item( + self, + collection_id: str, + item_id: str, + operations: List[stac.PatchOperation], + **kwargs, + ) -> Optional[Union[stac.Item, Response]]: + """Update an item from a collection. + + Called with `PATCH /collections/{collection_id}/items/{item_id}` + + Args: + item_id: id of the item. + collection_id: id of the collection. + operations: list of patch operations. + + Returns: + The patched item. + """ + ... + @abc.abstractmethod def delete_item( self, item_id: str, collection_id: str, **kwargs @@ -134,6 +230,89 @@ def update_collection( """ ... + def patch_collection( + self, + collection_id: str, + patch: Union[stac.PartialCollection, List[stac.PatchOperation]], + content_type: Optional[ + Literal[ + "application/json-patch+json", + "application/merge-patch+json", + "application/json", + ] + ] = "application/json", + **kwargs, + ) -> Optional[Union[stac.Collection, Response]]: + """Update a collection. + + Called with `PATCH /collections/{collection_id}` + + Args: + collection_id: id of the collection. + patch: either the partial collection or list of patch operations. + + Returns: + The patched collection. + """ + if isinstance(patch, list) and content_type == "application/json-patch+json": + return self.json_patch_collection(collection_id, patch, **kwargs) + + elif isinstance(patch, dict) and content_type in [ + "application/merge-patch+json", + "application/json", + ]: + partialCollectionValidator = TypeAdapter(stac.PartialCollection) + + patch = partialCollectionValidator.validate_python(patch) + return self.merge_patch_collection( + collection_id, + patch, + **kwargs, + ) + + else: + raise NotImplementedError("Content-Type and body combination not implemented") + + @abc.abstractmethod + def merge_patch_collection( + self, + collection_id: str, + collection: stac.PartialCollection, + **kwargs, + ) -> Optional[Union[stac.Collection, Response]]: + """Update a collection. + + Called with `PATCH /collections/{collection_id}` + + Args: + collection_id: id of the collection. + collection: the partial collection. + + Returns: + The patched collection. + """ + ... + + @abc.abstractmethod + def json_patch_collection( + self, + collection_id: str, + operations: List[stac.PatchOperation], + **kwargs, + ) -> Optional[Union[stac.Collection, Response]]: + """Update a collection. + + Called with `PATCH /collections/{collection_id}` + + Args: + collection_id: id of the collection. + operations: list of patch operations. + + Returns: + The patched collection. + """ + ... + @abc.abstractmethod def delete_collection( self, collection_id: str, **kwargs @@ -194,6 +373,101 @@ async def update_item( """ ... + async def patch_item( + self, + collection_id: str, + item_id: str, + patch: Union[stac.PartialItem, List[stac.PatchOperation]], + content_type: Optional[ + Literal[ + "application/json-patch+json", + "application/merge-patch+json", + "application/json", + ] + ] = "application/json", + **kwargs, + ) -> Optional[Union[stac.Item, Response]]: + """Update an item from a collection. + + Called with `PATCH /collections/{collection_id}/items/{item_id}` + + 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. + """ + if isinstance(patch, list) and content_type == "application/json-patch+json": + return await self.json_patch_item( + collection_id, + item_id, + patch, + **kwargs, + ) + + elif isinstance(patch, dict) and content_type in [ + "application/merge-patch+json", + "application/json", + ]: + partialItemValidator = TypeAdapter(stac.PartialItem) + + patch = partialItemValidator.validate_python(patch) + return await self.merge_patch_item( + collection_id, + item_id, + patch, + **kwargs, + ) + + else: + raise NotImplementedError("Content-Type and body combination not implemented") + + @abc.abstractmethod + async def merge_patch_item( + self, + collection_id: str, + item_id: str, + item: stac.PartialItem, + **kwargs, + ) -> Optional[Union[stac.Item, Response]]: + """Update an item from a collection. + + Called with `PATCH /collections/{collection_id}/items/{item_id}` + + Args: + item_id: id of the item. + collection_id: id of the collection. + item: the partial item. + + Returns: + The patched item. + """ + ... + + @abc.abstractmethod + async def json_patch_item( + self, + collection_id: str, + item_id: str, + operations: List[stac.PatchOperation], + **kwargs, + ) -> Optional[Union[stac.Item, Response]]: + """Update an item from a collection. + + Called with `PATCH /collections/{collection_id}/items/{item_id}` + + Args: + item_id: id of the item. + collection_id: id of the collection. + operations: list of patch operations. + + Returns: + The patched item. + """ + ... + @abc.abstractmethod async def delete_item( self, item_id: str, collection_id: str, **kwargs @@ -247,6 +521,89 @@ async def update_collection( """ ... + async def patch_collection( + self, + collection_id: str, + patch: Union[stac.PartialCollection, List[stac.PatchOperation]], + content_type: Optional[ + Literal[ + "application/json-patch+json", + "application/merge-patch+json", + "application/json", + ] + ] = "application/json", + **kwargs, + ) -> Optional[Union[stac.Collection, Response]]: + """Update a collection. + + Called with `PATCH /collections/{collection_id}` + + Args: + collection_id: id of the collection. + patch: either the partial collection or list of patch operations. + + Returns: + The patched collection. + """ + if isinstance(patch, list) and content_type == "application/json-patch+json": + partialCollectionValidator = TypeAdapter(stac.PartialCollection) + + patch = partialCollectionValidator.validate_python(patch) + return await self.json_patch_collection( + collection_id, + patch, + **kwargs, + ) + + elif isinstance(patch, dict) and content_type in [ + "application/merge-patch+json", + "application/json", + ]: + return await self.merge_patch_collection(collection_id, patch, **kwargs) + + else: + raise NotImplementedError("Content-Type and body combination not implemented") + + @abc.abstractmethod + async def merge_patch_collection( + self, + collection_id: str, + collection: stac.PartialCollection, + **kwargs, + ) -> Optional[Union[stac.Collection, Response]]: + """Update a collection. + + Called with `PATCH /collections/{collection_id}` + + Args: + collection_id: id of the collection. + collection: the partial collection. + + Returns: + The patched collection. + """ + ... + + @abc.abstractmethod + async def json_patch_collection( + self, + collection_id: str, + operations: List[stac.PatchOperation], + **kwargs, + ) -> Optional[Union[stac.Collection, Response]]: + """Update a collection. + + Called with `PATCH /collections/{collection_id}` + + Args: + collection_id: id of the collection. + operations: 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 b9c93fd8..10021591 100644 --- a/stac_fastapi/types/stac_fastapi/types/stac.py +++ b/stac_fastapi/types/stac_fastapi/types/stac.py @@ -1,18 +1,10 @@ """STAC types.""" -import sys from typing import Any, Dict, List, Literal, Optional, Union -from stac_pydantic.shared import BBox - -# Avoids a Pydantic error: -# TypeError: You should use `typing_extensions.TypedDict` instead of -# `typing.TypedDict` with Python < 3.12.0. Without it, there is no way to -# differentiate required and optional fields when subclassed. -if sys.version_info < (3, 12, 0): - from typing_extensions import TypedDict -else: - from typing import TypedDict +from pydantic import ConfigDict, Field +from stac_pydantic.shared import BBox, StacBaseModel +from typing_extensions import TypedDict NumType = Union[float, int] @@ -83,3 +75,64 @@ class Collections(TypedDict, total=False): collections: List[Collection] links: List[Dict[str, Any]] + + +class PartialCollection(TypedDict, total=False): + """Partial STAC Collection.""" + + type: Optional[str] + stac_version: Optional[str] + stac_extensions: Optional[List[str]] + id: Optional[str] + title: Optional[str] + description: Optional[str] + links: List[Dict[str, Any]] + keywords: Optional[List[str]] + license: Optional[str] + providers: Optional[List[Dict[str, Any]]] + extent: Optional[Dict[str, Any]] + summaries: Optional[Dict[str, Any]] + assets: Optional[Dict[str, Any]] + + +class PartialItem(TypedDict, total=False): + """Partial STAC Item.""" + + type: Optional[Literal["Feature"]] + stac_version: Optional[str] + stac_extensions: Optional[List[str]] + id: Optional[str] + geometry: Optional[Dict[str, Any]] + bbox: Optional[BBox] + properties: Optional[Dict[str, Any]] + links: Optional[List[Dict[str, Any]]] + assets: Optional[Dict[str, Any]] + collection: Optional[str] + + +class PatchAddReplaceTest(StacBaseModel): + """Add, Replace or Test Operation.""" + + path: str + op: Literal["add", "replace", "test"] + value: Any + + +class PatchRemove(StacBaseModel): + """Remove Operation.""" + + path: str + op: Literal["remove"] + + +class PatchMoveCopy(StacBaseModel): + """Move or Copy Operation.""" + + model_config = ConfigDict(populate_by_name=True) + + path: str + op: Literal["move", "copy"] + from_: str = Field(alias="from") + + +PatchOperation = Union[PatchAddReplaceTest, PatchMoveCopy, PatchRemove]