Skip to content

Commit 14f6d2c

Browse files
committed
REST: Use If-Match header for incoming requests
1 parent e39a831 commit 14f6d2c

File tree

3 files changed

+43
-55
lines changed

3 files changed

+43
-55
lines changed

doc/rest.txt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@ inverse of ``retire``, the item is again visible.
133133
On success the returned value is the same as the respective ``GET``
134134
method.
135135

136+
Note that the ``GET`` method on an item (e.g. ``/data/issue/43``)
137+
returns an ETag in the http header *and* the ``@etag`` value. When
138+
modifying the item via ``PUT`` or ``PATCH`` either a ``If-Match`` header
139+
or an ``@etag`` value in the form have to be provided.
140+
136141
sample python client
137142
====================
138143

@@ -163,7 +168,7 @@ Retire/Restore::
163168
>>> print("ETag: %s" % etag)
164169
>>> etag = r.json()['data']['@etag']
165170
>>> print("@etag: %s" % etag)
166-
>>> h = dict(ETag = etag)
171+
>>> h = {'If-Match': etag}
167172
>>> d = {'@op:'action', '@action_name':'retire'}
168173
>>> r = s.patch(u + 'issue/42', data = d, headers = h)
169174
>>> print(r.json())

roundup/rest.py

Lines changed: 22 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,8 @@ def calculate_etag (node, classname="Missing", id="0"):
135135
etag = md5(bs2b(repr(sorted(items)))).hexdigest()
136136
logger.debug("object=%s%s; tag=%s; repr=%s", classname, id,
137137
etag, repr(node.items(protected=True)))
138-
return etag
138+
# Quotes are part of ETag spec, normal headers don't have quotes
139+
return '"%s"' % etag
139140

140141
def check_etag (node, etags, classname="Missing", id="0"):
141142
'''Take a list of etags and compare to the etag for the given node.
@@ -151,7 +152,7 @@ def check_etag (node, etags, classname="Missing", id="0"):
151152
node_etag = calculate_etag(node, classname, id)
152153

153154
for etag in etags:
154-
if etag != None:
155+
if etag is not None:
155156
if etag != node_etag:
156157
return False
157158
have_etag_match=True
@@ -166,7 +167,7 @@ def obtain_etags(headers,input):
166167
etags = []
167168
if '@etag' in input:
168169
etags.append(input['@etag'].value);
169-
etags.append(headers.get("ETag", None))
170+
etags.append(headers.get("If-Match", None))
170171
return etags
171172

172173
def parse_accept_header(accept):
@@ -459,6 +460,16 @@ def patch_data(self, op, old_val, new_val):
459460

460461
return result
461462

463+
def raise_if_no_etag(self, class_name, item_id, input):
464+
class_obj = self.db.getclass(class_name)
465+
if not check_etag(class_obj.getnode(item_id),
466+
obtain_etags(self.client.request.headers, input),
467+
class_name,
468+
item_id):
469+
raise PreconditionFailed(
470+
"If-Match is missing or does not match."
471+
" Retrieve asset and retry modification if valid.")
472+
462473
@Routing.route("/data/<:class_name>", 'GET')
463474
@_data_decorator
464475
def get_collection(self, class_name, input):
@@ -669,7 +680,7 @@ def get_element(self, class_name, item_id, input):
669680
'@etag': etag
670681
}
671682

672-
self.client.setHeader("ETag", '"%s"'%etag)
683+
self.client.setHeader("ETag", etag)
673684
return 200, result
674685

675686
@Routing.route("/data/<:class_name>/<:item_id>/<:attr_name>", 'GET')
@@ -717,7 +728,7 @@ def get_attribute(self, class_name, item_id, attr_name, input):
717728
'@etag': etag
718729
}
719730

720-
self.client.setHeader("ETag", '"%s"'%etag )
731+
self.client.setHeader("ETag", etag)
721732
return 200, result
722733

723734
@Routing.route("/data/<:class_name>", 'POST')
@@ -818,12 +829,7 @@ def put_element(self, class_name, item_id, input):
818829
(p, class_name, item_id)
819830
)
820831
try:
821-
if not check_etag(class_obj.getnode(item_id),
822-
obtain_etags(self.client.request.headers, input),
823-
class_name,
824-
item_id):
825-
raise PreconditionFailed("Etag is missing or does not match."
826-
" Retrieve asset and retry modification if valid.")
832+
self.raise_if_no_etag(class_name, item_id, input)
827833
result = class_obj.set(item_id, **props)
828834
self.db.commit()
829835
except (TypeError, IndexError, ValueError) as message:
@@ -874,11 +880,7 @@ def put_attribute(self, class_name, item_id, attr_name, input):
874880
}
875881

876882
try:
877-
if not check_etag(class_obj.getnode(item_id),
878-
obtain_etags(self.client.request.headers, input),
879-
class_name, item_id):
880-
raise PreconditionFailed("Etag is missing or does not match."
881-
" Retrieve asset and retry modification if valid.")
883+
self.raise_if_no_etag(class_name, item_id, input)
882884
result = class_obj.set(item_id, **props)
883885
self.db.commit()
884886
except (TypeError, IndexError, ValueError) as message:
@@ -964,13 +966,7 @@ def delete_element(self, class_name, item_id, input):
964966
'Permission to retire %s %s denied' % (class_name, item_id)
965967
)
966968

967-
if not check_etag(class_obj.getnode(item_id),
968-
obtain_etags(self.client.request.headers, input),
969-
class_name,
970-
item_id):
971-
raise PreconditionFailed("Etag is missing or does not match."
972-
" Retrieve asset and retry modification if valid.")
973-
969+
self.raise_if_no_etag(class_name, item_id, input)
974970
class_obj.retire (item_id)
975971
self.db.commit()
976972
result = {
@@ -1014,13 +1010,7 @@ def delete_attribute(self, class_name, item_id, attr_name, input):
10141010
props[attr_name] = None
10151011

10161012
try:
1017-
if not check_etag(class_obj.getnode(item_id),
1018-
obtain_etags(self.client.request.headers, input),
1019-
class_name,
1020-
item_id):
1021-
raise PreconditionFailed("Etag is missing or does not match."
1022-
" Retrieve asset and retry modification if valid.")
1023-
1013+
self.raise_if_no_etag(class_name, item_id, input)
10241014
class_obj.set(item_id, **props)
10251015
self.db.commit()
10261016
except (TypeError, IndexError, ValueError) as message:
@@ -1064,12 +1054,7 @@ def patch_element(self, class_name, item_id, input):
10641054
op = self.__default_patch_op
10651055
class_obj = self.db.getclass(class_name)
10661056

1067-
if not check_etag(class_obj.getnode(item_id),
1068-
obtain_etags(self.client.request.headers, input),
1069-
class_name,
1070-
item_id):
1071-
raise PreconditionFailed("Etag is missing or does not match."
1072-
" Retrieve asset and retry modification if valid.")
1057+
self.raise_if_no_etag(class_name, item_id, input)
10731058

10741059
# if patch operation is action, call the action handler
10751060
action_args = [class_name + item_id]
@@ -1173,12 +1158,7 @@ def patch_attribute(self, class_name, item_id, attr_name, input):
11731158
prop = attr_name
11741159
class_obj = self.db.getclass(class_name)
11751160

1176-
if not check_etag(class_obj.getnode(item_id),
1177-
obtain_etags(self.client.request.headers, input),
1178-
class_name,
1179-
item_id):
1180-
raise PreconditionFailed("Etag is missing or does not match."
1181-
" Retrieve asset and retry modification if valid.")
1161+
self.raise_if_no_etag(class_name, item_id, input)
11821162

11831163
props = {
11841164
prop: self.prop_from_arg(

test/rest_common.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ def notestEtagGeneration(self):
375375
def testEtagProcessing(self):
376376
'''
377377
Etags can come from two places:
378-
ETag http header
378+
If-Match http header
379379
@etags value posted in the form
380380
381381
Both will be checked if availble. If either one
@@ -401,7 +401,7 @@ def testEtagProcessing(self):
401401

402402
if mode == 'header':
403403
print("Mode = %s"%mode)
404-
self.headers = {'etag': etag}
404+
self.headers = {'if-match': etag}
405405
elif mode == 'etag':
406406
print("Mode = %s"%mode)
407407
form.list.append(cgi.MiniFieldStorage('@etag', etag))
@@ -411,11 +411,11 @@ def testEtagProcessing(self):
411411
form.list.append(cgi.MiniFieldStorage('@etag', etag))
412412
elif mode == 'brokenheader':
413413
print("Mode = %s"%mode)
414-
self.headers = {'etag': 'bad'}
414+
self.headers = {'if-match': 'bad'}
415415
form.list.append(cgi.MiniFieldStorage('@etag', etag))
416416
elif mode == 'brokenetag':
417417
print("Mode = %s"%mode)
418-
self.headers = {'etag': etag}
418+
self.headers = {'if-match': etag}
419419
form.list.append(cgi.MiniFieldStorage('@etag', 'bad'))
420420
elif mode == 'none':
421421
print( "Mode = %s"%mode)
@@ -451,7 +451,7 @@ def testDispatch(self):
451451
headers={"accept": "application/json",
452452
"content-type": env['CONTENT_TYPE'],
453453
"content-length": env['CONTENT_LENGTH'],
454-
"etag": etag
454+
"if-match": etag
455455
}
456456
self.headers=headers
457457
# we need to generate a FieldStorage the looks like
@@ -478,7 +478,8 @@ def testDispatch(self):
478478
# simulate: /rest/data/user/<id>/realname
479479
# use etag in payload
480480
etag = calculate_etag(self.db.user.getnode(self.joeid))
481-
body=s2b('{ "@etag": "%s", "data": "Joe Doe 2" }'%etag)
481+
etagb = etag.strip ('"')
482+
body=s2b('{ "@etag": "\\"%s\\"", "data": "Joe Doe 2" }'%etagb)
482483
env = { "CONTENT_TYPE": "application/json",
483484
"CONTENT_LENGTH": len(body),
484485
"REQUEST_METHOD": "PUT",
@@ -492,7 +493,7 @@ def testDispatch(self):
492493

493494
headers={"accept": "application/json",
494495
"content-type": env['CONTENT_TYPE'],
495-
"etag": etag
496+
"if-match": etag
496497
}
497498
self.headers=headers # set for dispatch
498499

@@ -516,7 +517,7 @@ def testDispatch(self):
516517
# Also use GET on the uri via the dispatch to retrieve
517518
# the results from the db.
518519
etag = calculate_etag(self.db.user.getnode(self.joeid))
519-
headers={"etag": etag,
520+
headers={"if-match": etag,
520521
"accept": "application/json",
521522
}
522523
form = cgi.FieldStorage()
@@ -553,7 +554,8 @@ def testDispatch(self):
553554
self.assertEqual(self.dummy_client.response_code, 200)
554555

555556
etag = calculate_etag(self.db.user.getnode(self.joeid))
556-
body=s2b('{ "address": "[email protected]", "@etag": "%s"}'%etag)
557+
etagb = etag.strip ('"')
558+
body=s2b('{ "address": "[email protected]", "@etag": "\\"%s\\""}'%etagb)
557559
env = { "CONTENT_TYPE": "application/json",
558560
"CONTENT_LENGTH": len(body),
559561
"REQUEST_METHOD": "PATCH"
@@ -580,9 +582,10 @@ def testDispatch(self):
580582

581583
# and set it back reusing env and headers from last test
582584
etag = calculate_etag(self.db.user.getnode(self.joeid))
583-
body=s2b('{ "address": "%s", "@etag": "%s"}'%(
585+
etagb = etag.strip ('"')
586+
body=s2b('{ "address": "%s", "@etag": "\\"%s\\""}'%(
584587
stored_results['data']['attributes']['address'],
585-
etag))
588+
etagb))
586589
# reuse env and headers from prior test.
587590
body_file=BytesIO(body) # FieldStorage needs a file
588591
form = client.BinaryFieldStorage(body_file,
@@ -665,7 +668,7 @@ def testPut(self):
665668
cgi.MiniFieldStorage('data', 'Joe Doe Doe'),
666669
]
667670

668-
self.headers = {'etag': etag } # use etag in header
671+
self.headers = {'if-match': etag } # use etag in header
669672
results = self.server.put_attribute(
670673
'user', self.joeid, 'realname', form
671674
)

0 commit comments

Comments
 (0)