Skip to content

Commit ed9bb42

Browse files
committed
OARec/STAC: add support for HTTP PATCH (#1171)
1 parent 5e7b2ff commit ed9bb42

7 files changed

Lines changed: 142 additions & 25 deletions

File tree

docs/transactions.rst

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,12 @@ The below examples demonstrate transactional workflow using pycsw's OGC API - Re
100100
# insert GeoJSON metadata
101101
curl -v -H "Content-Type: application/geo+json" -XPOST http://localhost:8000/collections/metadata:main/items -d @foorecord.json
102102
103-
# update metadata
103+
# update (full) metadata
104104
curl -v -H "Content-Type: application/geo+json" -XPUT http://localhost:8000/collections/metadata:main/items/foorecord -d @foorecord.json
105105
106+
# update (partial) metadata
107+
curl -v -H "Content-Type: application/merge-patch+json" -XPATCH http://localhost:8000/collections/metadata:main/items/foorecord -d '{"properties": {"title": "newtitle"}}'
108+
106109
# delete metadata
107110
curl -v -XDELETE http://localhost:8000/collections/metadata:main/items/foorecord
108111
@@ -136,9 +139,12 @@ The below examples demonstrate transactional workflow using pycsw's OGC API - Re
136139
# insert STAC Item
137140
curl -v -H "Content-Type: application/json" -XPOST http://localhost:8000/stac/collections/metadata:main/items -d @fooitem.json
138141
139-
# update STAC Item
142+
# update (full) STAC Item
140143
curl -v -H "Content-Type: application/json" -XPUT http://localhost:8000/stac/collections/metadata:main/items/fooitem -d @fooitem.json
141144
145+
# update (partial) STAC Item
146+
curl -v -H "Content-Type: application/merge-patch+json" -XPATCH http://localhost:8000/stac/collections/metadata:main/items/fooitem -d '{"properties": {"title": "newtitle"}}'
147+
142148
# delete STAC Item
143149
curl -v -XDELETE http://localhost:8000/stac/collections/metadata:main/items/fooitem
144150
@@ -148,9 +154,12 @@ The below examples demonstrate transactional workflow using pycsw's OGC API - Re
148154
# insert STAC Collection
149155
curl -v -H "Content-Type: application/json" -XPOST http://localhost:8000/stac/collections -d @foocollection.json
150156
151-
# update STAC Collection
157+
# update (full) STAC Collection
152158
curl -v -H "Content-Type: application/json" -XPUT http://localhost:8000/stac/collections/foocollection -d @foocollection.json
153159
160+
# update (partial) STAC Collection
161+
curl -v -H "Content-Type: application/merge-patch+json" -XPATCH http://localhost:8000/stac/collections/foocollection -d '{"title": "newtitle"}'
162+
154163
# delete STAC Collection
155164
curl -v -XDELETE http://localhost:8000/stac/collections/foocollection
156165

pycsw/ogc/api/oapi.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -563,10 +563,10 @@ def gen_oapi(config, oapi_filepath, mode='ogcapi-records'):
563563
LOGGER.debug('Transactions enabled; adding put/delete')
564564

565565
path['put'] = {
566-
'summary': 'Updates Records items',
567-
'description': 'Updates Records items',
566+
'summary': 'Updates (full) Records items',
567+
'description': 'Updates (full) Records items',
568568
'tags': ['metadata'],
569-
'operationId': 'updateRecord',
569+
'operationId': 'replaceRecord',
570570
'consumes': [
571571
'application/geo+json', 'application/json', 'application/xml'
572572
],
@@ -583,7 +583,38 @@ def gen_oapi(config, oapi_filepath, mode='ogcapi-records'):
583583
{'$ref': '#/components/parameters/recordId'}
584584
],
585585
'responses': {
586-
'204': {'description': 'Successful update'},
586+
'204': {'description': 'Successful update (full)'},
587+
'400': {
588+
'$ref': '#/components/responses/InvalidParameter'
589+
},
590+
'500': {
591+
'$ref': '#/components/responses/ServerError'
592+
}
593+
}
594+
}
595+
596+
path['patch'] = {
597+
'summary': 'Updates (partial) Records items',
598+
'description': 'Updates (partial) Records items',
599+
'tags': ['metadata'],
600+
'operationId': 'updateRecord',
601+
'consumes': [
602+
'application/geo+json', 'application/json', 'application/xml'
603+
],
604+
'produces': ['application/json'],
605+
'requestBody': {
606+
'content': {
607+
'application/merge-patch+json': {
608+
'schema': {}
609+
}
610+
}
611+
},
612+
'parameters': [
613+
{'$ref': '#/components/parameters/collectionId'},
614+
{'$ref': '#/components/parameters/recordId'}
615+
],
616+
'responses': {
617+
'204': {'description': 'Successful update (partial)'},
587618
'400': {
588619
'$ref': '#/components/responses/InvalidParameter'
589620
},

pycsw/ogc/api/records.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from typing import List, Union
3838
from urllib.parse import urlencode, quote
3939

40+
import json_merge_patch
4041
from owslib.ogcapi.records import Records
4142
from pygeofilter.parsers.ecql import parse as parse_ecql
4243
from pygeofilter.parsers.cql2_json import parse as parse_cql2_json
@@ -1015,7 +1016,7 @@ def item(self, headers_, args, collection, item):
10151016
def manage_collection_item(self, headers_, action='create', collection=None,
10161017
item=None, data=None):
10171018
"""
1018-
:param action: action (create, update, delete)
1019+
:param action: action (create, replace, update, delete)
10191020
:param collection: collection identifier
10201021
:param item: record identifier
10211022
:param data: raw data / payload
@@ -1028,13 +1029,13 @@ def manage_collection_item(self, headers_, action='create', collection=None,
10281029
405, headers_, 'InvalidParameterValue',
10291030
'transactions not allowed')
10301031

1031-
if action in ['create', 'update'] and data is None:
1032+
if action in ['create', 'replace', 'update'] and data is None:
10321033
msg = 'No data found'
10331034
LOGGER.error(msg)
10341035
return self.get_exception(
10351036
400, headers_, 'InvalidParameterValue', msg)
10361037

1037-
if action in ['create', 'update']:
1038+
if action in ['create', 'replace']:
10381039
try:
10391040
record = parse_record(self.context, data, self.repository)[0]
10401041
except Exception as err:
@@ -1068,7 +1069,7 @@ def manage_collection_item(self, headers_, action='create', collection=None,
10681069
code = 201
10691070
response = {}
10701071

1071-
elif action == 'update':
1072+
elif action == 'replace':
10721073
LOGGER.debug(f'Querying repository for item {item}')
10731074
try:
10741075
_ = self.repository.query_ids([record.identifier])[0]
@@ -1082,6 +1083,40 @@ def manage_collection_item(self, headers_, action='create', collection=None,
10821083
code = 204
10831084
response = {}
10841085

1086+
elif action == 'update':
1087+
LOGGER.debug(f'Querying repository for item {item}')
1088+
try:
1089+
content = self.repository.query_ids([item])[0]
1090+
except Exception:
1091+
msg = 'Identifier does not exist'
1092+
LOGGER.debug(msg)
1093+
return self.get_exception(404, headers_, 'InvalidParameterValue', msg)
1094+
1095+
if 'id' in data:
1096+
msg = 'Update cannot alter id'
1097+
LOGGER.debug(msg)
1098+
return self.get_exception(400, headers_, 'InvalidParameterValue', msg)
1099+
1100+
content = json.loads(content.metadata)
1101+
record = json_merge_patch.merge(content, data)
1102+
1103+
try:
1104+
record = parse_record(self.context, record, self.repository)[0]
1105+
except Exception as err:
1106+
msg = f'Failed to parse data: {err}'
1107+
LOGGER.exception(msg)
1108+
return self.get_exception(400, headers_, 'InvalidParameterValue', msg)
1109+
1110+
if not hasattr(record, 'identifier'):
1111+
msg = 'Record requires an identifier'
1112+
LOGGER.exception(msg)
1113+
return self.get_exception(400, headers_, 'InvalidParameterValue', msg)
1114+
1115+
_ = self.repository.update(record)
1116+
1117+
code = 204
1118+
response = {}
1119+
10851120
elif action == 'delete':
10861121
constraint = {
10871122
'where': 'identifier = \'%s\'' % item,

pycsw/wsgi_flask.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656

5757
BLUEPRINT.config = api_.config
5858

59+
5960
def get_api_type(urlpath):
6061
"""
6162
Decorator to detect API type
@@ -157,8 +158,8 @@ def collections():
157158
return get_response(api_.collections(dict(request.headers), request.args))
158159

159160

160-
@BLUEPRINT.route('/collections/<collection>', methods=['GET', 'PUT', 'DELETE'])
161-
@BLUEPRINT.route('/stac/collections/<collection>', methods=['GET', 'PUT', 'DELETE'])
161+
@BLUEPRINT.route('/collections/<collection>', methods=['GET', 'PATCH', 'PUT', 'DELETE'])
162+
@BLUEPRINT.route('/stac/collections/<collection>', methods=['GET', 'PATCH', 'PUT', 'DELETE'])
162163
def collection(collection='metadata:main'):
163164
"""
164165
OGC API collection endpoint
@@ -172,12 +173,17 @@ def collection(collection='metadata:main'):
172173
if request.method == 'PUT':
173174
return get_response(
174175
stacapi.manage_collection_item(
175-
dict(request.headers), 'update', collection=collection,
176+
dict(request.headers), 'replace', collection=collection,
176177
data=request.get_json(silent=True)))
177178
elif request.method == 'DELETE':
178179
return get_response(
179180
stacapi.manage_collection_item(dict(request.headers),
180181
'delete', collection))
182+
elif request.method == 'PATCH':
183+
return get_response(
184+
api_.manage_collection_item(dict(request.headers), 'update',
185+
collection, item,
186+
data=request.get_json(silent=True)))
181187
else:
182188
return get_response(stacapi.collection(dict(request.headers),
183189
request.args, collection))
@@ -281,9 +287,9 @@ def items(collection='metadata:main'):
281287

282288

283289
@BLUEPRINT.route('/collections/<collection>/items/<path:item>',
284-
methods=['GET', 'PUT', 'DELETE'])
290+
methods=['GET', 'PATCH', 'PUT', 'DELETE'])
285291
@BLUEPRINT.route('/stac/collections/<collection>/items/<item>',
286-
methods=['GET', 'PUT', 'DELETE'])
292+
methods=['GET', 'PATCH', 'PUT', 'DELETE'])
287293
def item(collection='metadata:main', item=None):
288294
"""
289295
OGC API collection items endpoint
@@ -297,12 +303,17 @@ def item(collection='metadata:main', item=None):
297303
if request.method == 'PUT':
298304
return get_response(
299305
api_.manage_collection_item(
300-
dict(request.headers), 'update', collection, item,
306+
dict(request.headers), 'replace', collection, item,
301307
data=request.get_json(silent=True)))
302308
elif request.method == 'DELETE':
303309
return get_response(
304310
api_.manage_collection_item(dict(request.headers), 'delete',
305311
collection, item))
312+
elif request.method == 'PATCH':
313+
return get_response(
314+
api_.manage_collection_item(dict(request.headers), 'update',
315+
collection, item,
316+
data=request.get_json(silent=True)))
306317
else:
307318
if get_api_type(request.url_rule.rule) == 'stac-api':
308319
return get_response(stacapi.item(dict(request.headers), request.args,

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
click
22
geolinks
3+
json_merge_patch
34
legacy-cgi; python_version >= '3.13'
45
lxml
56
OWSLib

tests/functionaltests/suites/oarec/test_oarec_functional.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# Angelos Tzotsos <gcpp.kalxas@gmail.com>
55
# Ricardo Garcia Silva <ricardo.garcia.silva@gmail.com>
66
#
7-
# Copyright (c) 2025 Tom Kralidis
7+
# Copyright (c) 2026 Tom Kralidis
88
# Copyright (c) 2022 Angelos Tzotsos
99
# Copyright (c) 2023 Ricardo Garcia Silva
1010
#
@@ -341,11 +341,11 @@ def test_json_transaction(config, sample_record):
341341
element = e.find('{http://www.w3.org/2005/Atom}title').text
342342
assert element == 'title in English'
343343

344-
# update record
344+
# update (full) record
345345
sample_record['properties']['title'] = 'new title'
346346

347347
headers, status, content = api.manage_collection_item(
348-
request_headers, 'update', item='record-123', data=sample_record)
348+
request_headers, 'replace', item='record-123', data=sample_record)
349349

350350
assert status == 204
351351

@@ -355,6 +355,20 @@ def test_json_transaction(config, sample_record):
355355
assert content['id'] == 'record-123'
356356
assert content['properties']['title'] == 'new title'
357357

358+
# update (partial) record
359+
data2 = {'properties': {'title': 'new title merge patch'}}
360+
361+
headers, status, content = api.manage_collection_item(
362+
request_headers, 'update', item='record-123', data=data2)
363+
364+
assert status == 204
365+
366+
# test that record is in repository
367+
content = json.loads(api.item({}, {}, 'metadata:main', 'record-123')[2])
368+
369+
assert content['id'] == 'record-123'
370+
assert content['properties']['title'] == 'new title merge patch'
371+
358372
# test XML representation
359373
params = {'f': 'xml'}
360374
content = api.item({}, params, 'metadata:main', 'record-123')[2]
@@ -365,7 +379,7 @@ def test_json_transaction(config, sample_record):
365379
assert element == 'record-123'
366380

367381
element = e.find('{http://www.w3.org/2005/Atom}title').text
368-
assert element == 'new title'
382+
assert element == 'new title merge patch'
369383

370384
# delete record
371385
headers, status, content = api.manage_collection_item(
@@ -455,7 +469,7 @@ def test_xml_transaction(config):
455469
b'Ut facilisis justo ut lacus', b'new title')
456470

457471
headers, status, content = api.manage_collection_item(
458-
request_headers, 'update', item='record-456', data=test_data_xml)
472+
request_headers, 'replace', item='record-456', data=test_data_xml)
459473

460474
assert status == 204
461475

tests/functionaltests/suites/stac_api/test_stac_api_functional.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -902,7 +902,7 @@ def test_json_transaction(config, sample_collection, sample_item,
902902
sample_item['properties']['datetime'] = '2021-12-14T22:38:32Z'
903903

904904
headers, status, content = api.manage_collection_item(
905-
request_headers, 'update', item='20201211_223832_CS2',
905+
request_headers, 'replace', item='20201211_223832_CS2',
906906
data=sample_item, collection='metadata:main')
907907

908908
assert status == 204
@@ -994,11 +994,11 @@ def test_json_transaction(config, sample_collection, sample_item,
994994

995995
assert content['numberMatched'] == 0
996996

997-
# update collection
997+
# update (full) collection
998998
sample_collection['title'] = 'test title update'
999999

10001000
headers, status, content = api.manage_collection_item(
1001-
request_headers, 'update', item=collection_id,
1001+
request_headers, 'replace', item=collection_id,
10021002
data=sample_collection, collection='metadata:main')
10031003

10041004
assert status == 204
@@ -1010,6 +1010,22 @@ def test_json_transaction(config, sample_collection, sample_item,
10101010

10111011
assert content['title'] == sample_collection['title']
10121012

1013+
# update (partial) collection
1014+
data2 = {'title': 'test title update merge patch'}
1015+
1016+
headers, status, content = api.manage_collection_item(
1017+
request_headers, 'update', item=collection_id,
1018+
data=data2, collection='metadata:main')
1019+
1020+
assert status == 204
1021+
1022+
headers, status, content = api.collection(
1023+
{}, {'f': 'json'}, collection=collection_id)
1024+
1025+
content = json.loads(content)
1026+
1027+
assert content['title'] == 'test title update merge patch'
1028+
10131029
# test that item is in repository
10141030
content = json.loads(api.item({}, {}, 'metadata:main',
10151031
'20201211_223832_CS2')[2])

0 commit comments

Comments
 (0)