Skip to content

Commit 29b40d3

Browse files
committed
OARec/STAC: add support for HTTP PATCH (#1171)
1 parent 88f315d commit 29b40d3

7 files changed

Lines changed: 141 additions & 24 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
@@ -661,10 +661,10 @@ def gen_oapi(config, oapi_filepath, mode='ogcapi-records'):
661661
LOGGER.debug('Transactions enabled; adding put/delete')
662662

663663
path['put'] = {
664-
'summary': 'Updates Records items',
665-
'description': 'Updates Records items',
664+
'summary': 'Updates (full) Records items',
665+
'description': 'Updates (full) Records items',
666666
'tags': ['metadata'],
667-
'operationId': 'updateRecord',
667+
'operationId': 'replaceRecord',
668668
'consumes': [
669669
'application/geo+json', 'application/json', 'application/xml'
670670
],
@@ -681,7 +681,38 @@ def gen_oapi(config, oapi_filepath, mode='ogcapi-records'):
681681
{'$ref': '#/components/parameters/recordId'}
682682
],
683683
'responses': {
684-
'204': {'description': 'Successful update'},
684+
'204': {'description': 'Successful update (full)'},
685+
'400': {
686+
'$ref': '#/components/responses/InvalidParameter'
687+
},
688+
'500': {
689+
'$ref': '#/components/responses/ServerError'
690+
}
691+
}
692+
}
693+
694+
path['patch'] = {
695+
'summary': 'Updates (partial) Records items',
696+
'description': 'Updates (partial) Records items',
697+
'tags': ['metadata'],
698+
'operationId': 'updateRecord',
699+
'consumes': [
700+
'application/geo+json', 'application/json', 'application/xml'
701+
],
702+
'produces': ['application/json'],
703+
'requestBody': {
704+
'content': {
705+
'application/merge-patch+json': {
706+
'schema': {}
707+
}
708+
}
709+
},
710+
'parameters': [
711+
{'$ref': '#/components/parameters/collectionId'},
712+
{'$ref': '#/components/parameters/recordId'}
713+
],
714+
'responses': {
715+
'204': {'description': 'Successful update (partial)'},
685716
'400': {
686717
'$ref': '#/components/responses/InvalidParameter'
687718
},

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
@@ -1068,7 +1069,7 @@ def item(self, headers_, args, collection, item):
10681069
def manage_collection_item(self, headers_, action='create', collection=None,
10691070
item=None, data=None):
10701071
"""
1071-
:param action: action (create, update, delete)
1072+
:param action: action (create, replace, update, delete)
10721073
:param collection: collection identifier
10731074
:param item: record identifier
10741075
:param data: raw data / payload
@@ -1081,13 +1082,13 @@ def manage_collection_item(self, headers_, action='create', collection=None,
10811082
405, headers_, 'InvalidParameterValue',
10821083
'transactions not allowed')
10831084

1084-
if action in ['create', 'update'] and data is None:
1085+
if action in ['create', 'replace', 'update'] and data is None:
10851086
msg = 'No data found'
10861087
LOGGER.error(msg)
10871088
return self.get_exception(
10881089
400, headers_, 'InvalidParameterValue', msg)
10891090

1090-
if action in ['create', 'update']:
1091+
if action in ['create', 'replace']:
10911092
try:
10921093
record = parse_record(self.context, data, self.repository)[0]
10931094
except Exception as err:
@@ -1121,7 +1122,7 @@ def manage_collection_item(self, headers_, action='create', collection=None,
11211122
code = 201
11221123
response = {}
11231124

1124-
elif action == 'update':
1125+
elif action == 'replace':
11251126
LOGGER.debug(f'Querying repository for item {item}')
11261127
try:
11271128
_ = self.repository.query_ids([record.identifier])[0]
@@ -1135,6 +1136,40 @@ def manage_collection_item(self, headers_, action='create', collection=None,
11351136
code = 204
11361137
response = {}
11371138

1139+
elif action == 'update':
1140+
LOGGER.debug(f'Querying repository for item {item}')
1141+
try:
1142+
content = self.repository.query_ids([item])[0]
1143+
except Exception:
1144+
msg = 'Identifier does not exist'
1145+
LOGGER.debug(msg)
1146+
return self.get_exception(404, headers_, 'InvalidParameterValue', msg)
1147+
1148+
if 'id' in data:
1149+
msg = 'Update cannot alter id'
1150+
LOGGER.debug(msg)
1151+
return self.get_exception(400, headers_, 'InvalidParameterValue', msg)
1152+
1153+
content = json.loads(content.metadata)
1154+
record = json_merge_patch.merge(content, data)
1155+
1156+
try:
1157+
record = parse_record(self.context, record, self.repository)[0]
1158+
except Exception as err:
1159+
msg = f'Failed to parse data: {err}'
1160+
LOGGER.exception(msg)
1161+
return self.get_exception(400, headers_, 'InvalidParameterValue', msg)
1162+
1163+
if not hasattr(record, 'identifier'):
1164+
msg = 'Record requires an identifier'
1165+
LOGGER.exception(msg)
1166+
return self.get_exception(400, headers_, 'InvalidParameterValue', msg)
1167+
1168+
_ = self.repository.update(record)
1169+
1170+
code = 204
1171+
response = {}
1172+
11381173
elif action == 'delete':
11391174
constraint = {
11401175
'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))
@@ -295,9 +301,9 @@ def items(collection='metadata:main'):
295301

296302

297303
@BLUEPRINT.route('/collections/<collection>/items/<path:item>',
298-
methods=['GET', 'PUT', 'DELETE'])
304+
methods=['GET', 'PATCH', 'PUT', 'DELETE'])
299305
@BLUEPRINT.route('/stac/collections/<collection>/items/<item>',
300-
methods=['GET', 'PUT', 'DELETE'])
306+
methods=['GET', 'PATCH', 'PUT', 'DELETE'])
301307
def item(collection='metadata:main', item=None):
302308
"""
303309
OGC API collection items endpoint
@@ -311,12 +317,17 @@ def item(collection='metadata:main', item=None):
311317
if request.method == 'PUT':
312318
return get_response(
313319
api_.manage_collection_item(
314-
dict(request.headers), 'update', collection, item,
320+
dict(request.headers), 'replace', collection, item,
315321
data=request.get_json(silent=True)))
316322
elif request.method == 'DELETE':
317323
return get_response(
318324
api_.manage_collection_item(dict(request.headers), 'delete',
319325
collection, item))
326+
elif request.method == 'PATCH':
327+
return get_response(
328+
api_.manage_collection_item(dict(request.headers), 'update',
329+
collection, item,
330+
data=request.get_json(silent=True)))
320331
else:
321332
if get_api_type(request.url_rule.rule) == 'stac-api':
322333
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: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -365,11 +365,11 @@ def test_json_transaction(config, sample_record):
365365
element = e.find('{http://www.w3.org/2005/Atom}title').text
366366
assert element == 'title in English'
367367

368-
# update record
368+
# update (full) record
369369
sample_record['properties']['title'] = 'new title'
370370

371371
headers, status, content = api.manage_collection_item(
372-
request_headers, 'update', item='record-123', data=sample_record)
372+
request_headers, 'replace', item='record-123', data=sample_record)
373373

374374
assert status == 204
375375

@@ -379,6 +379,20 @@ def test_json_transaction(config, sample_record):
379379
assert content['id'] == 'record-123'
380380
assert content['properties']['title'] == 'new title'
381381

382+
# update (partial) record
383+
data2 = {'properties': {'title': 'new title merge patch'}}
384+
385+
headers, status, content = api.manage_collection_item(
386+
request_headers, 'update', item='record-123', data=data2)
387+
388+
assert status == 204
389+
390+
# test that record is in repository
391+
content = json.loads(api.item({}, {}, 'metadata:main', 'record-123')[2])
392+
393+
assert content['id'] == 'record-123'
394+
assert content['properties']['title'] == 'new title merge patch'
395+
382396
# test XML representation
383397
params = {'f': 'xml'}
384398
content = api.item({}, params, 'metadata:main', 'record-123')[2]
@@ -389,7 +403,7 @@ def test_json_transaction(config, sample_record):
389403
assert element == 'record-123'
390404

391405
element = e.find('{http://www.w3.org/2005/Atom}title').text
392-
assert element == 'new title'
406+
assert element == 'new title merge patch'
393407

394408
# delete record
395409
headers, status, content = api.manage_collection_item(
@@ -479,7 +493,7 @@ def test_xml_transaction(config):
479493
b'Ut facilisis justo ut lacus', b'new title')
480494

481495
headers, status, content = api.manage_collection_item(
482-
request_headers, 'update', item='record-456', data=test_data_xml)
496+
request_headers, 'replace', item='record-456', data=test_data_xml)
483497

484498
assert status == 204
485499

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)