Skip to content

Commit a891cfa

Browse files
authored
Add support for Patch endpoints (#291)
**Description:** Adds PATCH endpoints to transaction extension. Adds support for [RFC 6902](https://tools.ietf.org/html/rfc6902) and [RFC 7396](https://tools.ietf.org/html/rfc7396). Pivots on header Content-Type value. Related pull requests: - stac-utils/stac-fastapi#744 - stac-api-extensions/transaction#14 **PR Checklist:** - [x] Code is formatted and linted (run `pre-commit run --all-files`) - [x] Tests pass (run `make test`) - [ ] Documentation has been updated to reflect changes, if applicable - [x] Changes are added to the changelog
1 parent 4dafa28 commit a891cfa

File tree

17 files changed

+1885
-41
lines changed

17 files changed

+1885
-41
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
88

99
## [Unreleased]
1010

11+
### Added
12+
13+
- Added support for PATCH update through [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902) and [RFC 7396](https://datatracker.ietf.org/doc/html/rfc7396) [#291](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/291)
14+
1115
## [v5.0.0] - 2025-06-11
1216

1317
### Added

dockerfiles/Dockerfile.deploy.es

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ FROM python:3.10-slim
33
RUN apt-get update && \
44
apt-get -y upgrade && \
55
apt-get -y install gcc && \
6+
apt-get -y install build-essential git && \
67
apt-get clean && \
78
rm -rf /var/lib/apt/lists/*
89

10+
11+
912
ENV CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
1013

1114
WORKDIR /app

dockerfiles/Dockerfile.dev.es

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ FROM python:3.10-slim
44
# update apt pkgs, and install build-essential for ciso8601
55
RUN apt-get update && \
66
apt-get -y upgrade && \
7-
apt-get install -y build-essential git && \
7+
apt-get -y install build-essential git && \
88
apt-get clean && \
99
rm -rf /var/lib/apt/lists/*
1010

dockerfiles/Dockerfile.dev.os

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ FROM python:3.10-slim
44
# update apt pkgs, and install build-essential for ciso8601
55
RUN apt-get update && \
66
apt-get -y upgrade && \
7-
apt-get install -y build-essential && \
7+
apt-get -y install build-essential && \
88
apt-get clean && \
99
rm -rf /var/lib/apt/lists/*
1010

11+
RUN apt-get -y install git
1112
# update certs used by Requests
1213
ENV CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
1314

dockerfiles/Dockerfile.docs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM python:3.8-slim
1+
FROM python:3.9-slim
22

33
# build-essential is required to build a wheel for ciso8601
44
RUN apt update && apt install -y build-essential

stac_fastapi/core/setup.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
"fastapi~=0.109.0",
1010
"attrs>=23.2.0",
1111
"pydantic>=2.4.1,<3.0.0",
12-
"stac_pydantic~=3.1.0",
13-
"stac-fastapi.api==5.2.0",
14-
"stac-fastapi.extensions==5.2.0",
15-
"stac-fastapi.types==5.2.0",
12+
"stac_pydantic~=3.3.0",
13+
"stac-fastapi.types==6.0.0",
14+
"stac-fastapi.api==6.0.0",
15+
"stac-fastapi.extensions==6.0.0",
1616
"orjson~=3.9.0",
1717
"overrides~=7.4.0",
1818
"geojson-pydantic~=1.0.0",

stac_fastapi/core/stac_fastapi/core/base_database_logic.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,30 @@ async def create_item(self, item: Dict, refresh: bool = False) -> None:
2929
"""Create an item in the database."""
3030
pass
3131

32+
@abc.abstractmethod
33+
async def merge_patch_item(
34+
self,
35+
collection_id: str,
36+
item_id: str,
37+
item: Dict,
38+
base_url: str,
39+
refresh: bool = True,
40+
) -> Dict:
41+
"""Patch a item in the database follows RF7396."""
42+
pass
43+
44+
@abc.abstractmethod
45+
async def json_patch_item(
46+
self,
47+
collection_id: str,
48+
item_id: str,
49+
operations: List,
50+
base_url: str,
51+
refresh: bool = True,
52+
) -> Dict:
53+
"""Patch a item in the database follows RF6902."""
54+
pass
55+
3256
@abc.abstractmethod
3357
async def delete_item(
3458
self, item_id: str, collection_id: str, refresh: bool = False
@@ -53,6 +77,28 @@ async def create_collection(self, collection: Dict, refresh: bool = False) -> No
5377
"""Create a collection in the database."""
5478
pass
5579

80+
@abc.abstractmethod
81+
async def merge_patch_collection(
82+
self,
83+
collection_id: str,
84+
collection: Dict,
85+
base_url: str,
86+
refresh: bool = True,
87+
) -> Dict:
88+
"""Patch a collection in the database follows RF7396."""
89+
pass
90+
91+
@abc.abstractmethod
92+
async def json_patch_collection(
93+
self,
94+
collection_id: str,
95+
operations: List,
96+
base_url: str,
97+
refresh: bool = True,
98+
) -> Dict:
99+
"""Patch a collection in the database follows RF6902."""
100+
pass
101+
56102
@abc.abstractmethod
57103
async def find_collection(self, collection_id: str) -> Dict:
58104
"""Find a collection in the database."""

stac_fastapi/core/stac_fastapi/core/core.py

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import orjson
1212
from fastapi import HTTPException, Request
1313
from overrides import overrides
14-
from pydantic import ValidationError
14+
from pydantic import TypeAdapter, ValidationError
1515
from pygeofilter.backends.cql2_json import to_cql2
1616
from pygeofilter.parsers.cql2_text import parse as parse_cql2_text
1717
from stac_pydantic import Collection, Item, ItemCollection
@@ -26,20 +26,29 @@
2626
from stac_fastapi.core.serializers import CollectionSerializer, ItemSerializer
2727
from stac_fastapi.core.session import Session
2828
from stac_fastapi.core.utilities import filter_fields
29+
from stac_fastapi.extensions.core.transaction import AsyncBaseTransactionsClient
30+
from stac_fastapi.extensions.core.transaction.request import (
31+
PartialCollection,
32+
PartialItem,
33+
PatchOperation,
34+
)
2935
from stac_fastapi.extensions.third_party.bulk_transactions import (
3036
BaseBulkTransactionsClient,
3137
BulkTransactionMethod,
3238
Items,
3339
)
3440
from stac_fastapi.types import stac as stac_types
3541
from stac_fastapi.types.conformance import BASE_CONFORMANCE_CLASSES
36-
from stac_fastapi.types.core import AsyncBaseCoreClient, AsyncBaseTransactionsClient
42+
from stac_fastapi.types.core import AsyncBaseCoreClient
3743
from stac_fastapi.types.extension import ApiExtension
3844
from stac_fastapi.types.requests import get_base_url
3945
from stac_fastapi.types.search import BaseSearchPostRequest
4046

4147
logger = logging.getLogger(__name__)
4248

49+
partialItemValidator = TypeAdapter(PartialItem)
50+
partialCollectionValidator = TypeAdapter(PartialCollection)
51+
4352

4453
@attr.s
4554
class CoreClient(AsyncBaseCoreClient):
@@ -680,6 +689,63 @@ async def update_item(
680689

681690
return ItemSerializer.db_to_stac(item, base_url)
682691

692+
@overrides
693+
async def patch_item(
694+
self,
695+
collection_id: str,
696+
item_id: str,
697+
patch: Union[PartialItem, List[PatchOperation]],
698+
**kwargs,
699+
):
700+
"""Patch an item in the collection.
701+
702+
Args:
703+
collection_id (str): The ID of the collection the item belongs to.
704+
item_id (str): The ID of the item to be updated.
705+
patch (Union[PartialItem, List[PatchOperation]]): The item data or operations.
706+
kwargs: Other optional arguments, including the request object.
707+
708+
Returns:
709+
stac_types.Item: The updated item object.
710+
711+
Raises:
712+
NotFound: If the specified collection is not found in the database.
713+
714+
"""
715+
base_url = str(kwargs["request"].base_url)
716+
717+
content_type = kwargs["request"].headers.get("content-type")
718+
719+
item = None
720+
if isinstance(patch, list) and content_type == "application/json-patch+json":
721+
item = await self.database.json_patch_item(
722+
collection_id=collection_id,
723+
item_id=item_id,
724+
operations=patch,
725+
base_url=base_url,
726+
)
727+
728+
if isinstance(patch, dict):
729+
patch = partialItemValidator.validate_python(patch)
730+
731+
if isinstance(patch, PartialItem) and content_type in [
732+
"application/merge-patch+json",
733+
"application/json",
734+
]:
735+
item = await self.database.merge_patch_item(
736+
collection_id=collection_id,
737+
item_id=item_id,
738+
item=patch,
739+
base_url=base_url,
740+
)
741+
742+
if item:
743+
return ItemSerializer.db_to_stac(item, base_url=base_url)
744+
745+
raise NotImplementedError(
746+
f"Content-Type: {content_type} and body: {patch} combination not implemented"
747+
)
748+
683749
@overrides
684750
async def delete_item(self, item_id: str, collection_id: str, **kwargs) -> None:
685751
"""Delete an item from a collection.
@@ -761,6 +827,59 @@ async def update_collection(
761827
extensions=[type(ext).__name__ for ext in self.database.extensions],
762828
)
763829

830+
@overrides
831+
async def patch_collection(
832+
self,
833+
collection_id: str,
834+
patch: Union[PartialCollection, List[PatchOperation]],
835+
**kwargs,
836+
):
837+
"""Update a collection.
838+
839+
Called with `PATCH /collections/{collection_id}`
840+
841+
Args:
842+
collection_id: id of the collection.
843+
patch: either the partial collection or list of patch operations.
844+
845+
Returns:
846+
The patched collection.
847+
"""
848+
base_url = str(kwargs["request"].base_url)
849+
content_type = kwargs["request"].headers.get("content-type")
850+
851+
collection = None
852+
if isinstance(patch, list) and content_type == "application/json-patch+json":
853+
collection = await self.database.json_patch_collection(
854+
collection_id=collection_id,
855+
operations=patch,
856+
base_url=base_url,
857+
)
858+
859+
if isinstance(patch, dict):
860+
patch = partialCollectionValidator.validate_python(patch)
861+
862+
if isinstance(patch, PartialCollection) and content_type in [
863+
"application/merge-patch+json",
864+
"application/json",
865+
]:
866+
collection = await self.database.merge_patch_collection(
867+
collection_id=collection_id,
868+
collection=patch,
869+
base_url=base_url,
870+
)
871+
872+
if collection:
873+
return CollectionSerializer.db_to_stac(
874+
collection,
875+
kwargs["request"],
876+
extensions=[type(ext).__name__ for ext in self.database.extensions],
877+
)
878+
879+
raise NotImplementedError(
880+
f"Content-Type: {content_type} and body: {patch} combination not implemented"
881+
)
882+
764883
@overrides
765884
async def delete_collection(self, collection_id: str, **kwargs) -> None:
766885
"""

stac_fastapi/core/stac_fastapi/core/utilities.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
This module contains functions for transforming geospatial coordinates,
44
such as converting bounding boxes to polygon representations.
55
"""
6+
67
import logging
78
import os
89
from typing import Any, Dict, List, Optional, Set, Union

0 commit comments

Comments
 (0)