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 27 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
27 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
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)

### Added

* Add `from_extensions()` method to `CollectionSearchExtension` and `CollectionSearchPostExtension` extensions to build the class based on a list of available extensions.
Expand All @@ -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
Expand Down
17 changes: 13 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,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"

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

Expand Down
105 changes: 94 additions & 11 deletions stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -30,13 +31,53 @@ 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."""

collection: Annotated[Collection, Body()] = attr.ib(default=None)
rhysrevans3 marked this conversation as resolved.
Show resolved Hide resolved


@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.
Expand Down Expand Up @@ -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})."""
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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.

Expand All @@ -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"])
95 changes: 94 additions & 1 deletion stac_fastapi/extensions/tests/test_transaction.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand All @@ -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}

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