Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Patch endpoints #744

Open
wants to merge 46 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
0cddff7
Adding patch endpoints.
rhysrevans3 Aug 20, 2024
14d18f9
Merge branch 'main' of github.com:stac-utils/stac-fastapi into patch_…
rhysrevans3 Aug 20, 2024
0c5de64
Adding annotated from main.
rhysrevans3 Aug 20, 2024
bf2ddbb
Fixing and adding tests.
rhysrevans3 Aug 21, 2024
632d5a5
Updating changelog.
rhysrevans3 Aug 21, 2024
5f2b4fa
Fixing ruff errors.
rhysrevans3 Aug 21, 2024
010e2cb
Ruff format.
rhysrevans3 Aug 21, 2024
0ccded0
Switching to List for python 3.8.
rhysrevans3 Aug 21, 2024
1b46754
Updating docs make file.
rhysrevans3 Aug 21, 2024
79c769c
Switching from Item/Collection to Dict to allow partial updates.
rhysrevans3 Aug 23, 2024
68a65a0
Ruff format fix.
rhysrevans3 Aug 23, 2024
88b40d4
Fixing broken tests.
rhysrevans3 Aug 23, 2024
b7bcbd5
Adding missing asyncs for patchs.
rhysrevans3 Aug 23, 2024
cfc31c6
Moving request to kwargs for patch item and collection.
rhysrevans3 Aug 23, 2024
81dbcad
Switching to TypedDict.
rhysrevans3 Aug 28, 2024
f053f07
Merge branch 'main' of github.com:stac-utils/stac-fastapi into patch_…
rhysrevans3 Aug 28, 2024
bce099c
Adding hearder parameter to the input models.
rhysrevans3 Aug 28, 2024
336df70
Removing print statement.
rhysrevans3 Aug 28, 2024
36b7167
Removing basemodel from patch types.
rhysrevans3 Aug 28, 2024
0ecf3e5
Fixing imports.
rhysrevans3 Aug 28, 2024
47a0b48
Moving models to correct locations.
rhysrevans3 Aug 28, 2024
13a2377
Switching from attrs to basemodel for patch operations.
rhysrevans3 Aug 28, 2024
7e59d13
Switching to stac.PartialItem etc.
rhysrevans3 Aug 28, 2024
9d011eb
Updating PatchMoveCopy model.
rhysrevans3 Sep 3, 2024
ae6bb94
Merge branch 'main' of github.com:stac-utils/stac-fastapi into patch_…
rhysrevans3 Sep 4, 2024
e325cb2
Updating type for 3.8.
rhysrevans3 Sep 4, 2024
fefd493
Switching to StacBaseModels for patch operations.
rhysrevans3 Sep 18, 2024
7611903
Merge branch 'main' of github.com:stac-utils/stac-fastapi into patch_…
rhysrevans3 Mar 25, 2025
5319c3b
pre-commits.
rhysrevans3 Mar 25, 2025
a434c25
Add json dump of operation value.
rhysrevans3 Mar 26, 2025
8863923
remove computed field decorator.
rhysrevans3 Mar 26, 2025
25ff3e1
Adding default "not implemented" for JSON Patch.
rhysrevans3 Apr 2, 2025
3d49658
pre-commit.
rhysrevans3 Apr 2, 2025
0254336
Add default raise not implement to collection JSON Patch.
rhysrevans3 Apr 2, 2025
1198000
removing json merge and patch.
rhysrevans3 Apr 2, 2025
3bdfcc7
content_type None default.
rhysrevans3 Apr 3, 2025
0ddd072
Fixing test.
rhysrevans3 Apr 3, 2025
4238ae9
back to PartialItem & PatchOperation
rhysrevans3 Apr 3, 2025
41aba4d
using openapi_extra for Content-Type.
rhysrevans3 Apr 3, 2025
46f4edf
PartialCollection not PartialItem.
rhysrevans3 Apr 3, 2025
c1003d4
Adding merge to operations.
rhysrevans3 Apr 3, 2025
53bfe47
Adding example code for patch.
rhysrevans3 Apr 3, 2025
2ce9e54
Adding default None to partials.
rhysrevans3 Apr 3, 2025
178ea59
patch not update for backend.
rhysrevans3 Apr 3, 2025
be4962f
patch_collection not patch_item.
rhysrevans3 Apr 3, 2025
4189df8
Add tests for merge to operations.
rhysrevans3 Apr 3, 2025
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
5 changes: 3 additions & 2 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

## [5.1.1] - 2025-03-17

### Fixed
Expand Down Expand Up @@ -139,8 +141,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
Expand Down
11 changes: 7 additions & 4 deletions stac_fastapi/api/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


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

Expand All @@ -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"

Expand Down
184 changes: 175 additions & 9 deletions stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 JSONResponse, Response
Expand All @@ -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):
Expand Down Expand Up @@ -42,13 +44,33 @@ 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."""

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)


@attr.s
class TransactionExtension(ApiExtension):
"""Transaction Extension.
Expand Down Expand Up @@ -126,6 +148,82 @@ 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": 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",
},
]
]
},
},
"application/merge-patch+json": {
"schema": TypeAdapter(PartialItem).json_schema(),
},
"application/json": {
"schema": TypeAdapter(PartialItem).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})."""
Expand All @@ -148,11 +246,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(
Expand Down Expand Up @@ -196,6 +289,81 @@ 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": 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",
},
]
]
},
},
"application/merge-patch+json": {
"schema": TypeAdapter(PartialCollection).json_schema(),
},
"application/json": {
"schema": TypeAdapter(PartialCollection).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(
Expand All @@ -217,10 +385,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.

Expand All @@ -233,8 +397,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"])
50 changes: 50 additions & 0 deletions stac_fastapi/extensions/tests/test_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -46,6 +47,19 @@ 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, PatchOperation],
**kwargs,
):
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,
Expand All @@ -58,6 +72,17 @@ 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, PatchOperation],
**kwargs,
):
return {
"path_collection_id": collection_id,
"patch": patch,
}

def delete_collection(self, collection_id: str, **kwargs):
return {"path_collection_id": collection_id}

Expand Down Expand Up @@ -88,6 +113,19 @@ def test_update_item(client: TestClient, item: Item) -> None:
assert response.json()["type"] == "Feature"


def test_patch_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_delete_item(client: TestClient) -> None:
response = client.delete("/collections/a-collection/items/an-item")
assert response.is_success, response.text
Expand All @@ -108,6 +146,18 @@ def test_update_collection(client: TestClient, collection: Collection) -> None:
assert response.json()["type"] == "Collection"


def test_patch_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_delete_collection(client: TestClient, collection: Collection) -> None:
response = client.delete("/collections/a-collection")
assert response.is_success, response.text
Expand Down
Loading