Skip to content

Commit 20f9359

Browse files
committed
Add support for making an idempotent POST. This allows retrying a POST
that was interrupted. It involves creating a post once only (poe) url /rest/data/<class>/@poe/<random_token>. This url acts the same as a post to /rest/data/<class>. However once the @poe url is used, it can't be used for a second POST. To make these changes: 1) Take the body of post_collection into a new post_collection_inner function. Have post_collection call post_collection_inner. 2) Add a handler for POST to rest/data/class/@poe. This will return a unique POE url. By default the url expires after 30 minutes. The POE random token is only good for a specific user and is stored in the session db. 3) Add a handler for POST to rest/data/<class>/@poe/<random token>. The random token generated in 2 is validated for proper class (if token is not generic) and proper user and must not have expired. If everything is valid, call post_collection_inner to process the input and generate the new entry. To make recognition of 2 stable (so it's not confused with rest/data/<:class_name>/<:item_id>), removed @ from Routing::url_to_regex. The current Routing.execute method stops on the first regular expression to match the URL. Since item_id doesn't accept a POST, I was getting 405 bad method sometimes. My guess is the order of the regular expressions is not stable, so sometime I would get the right regexp for /data/<class>/@poe and sometime I would get the one for /data/<class>/<item_id>. By removing the @ from the url_to_regexp, there was no way for the item_id case to match @poe. There are alternate fixes we may need to look at. If a regexp matches but the method does not, return to the regexp matching loop in execute() looking for another match. Only once every possible match has failed should the code return a 405 method failure. Another fix is to implement a more sophisticated mechanism so that @Routing.route("/data/<:class_name>/<:item_id>/<:attr_name>", 'PATCH') has different regexps for matching <:class_name> <:item_id> and <:attr_name>. Currently the regexp specified by url_to_regex is used for every component. Other fixes: Made failure to find any props in props_from_args return an empty dict rather than throwing an unhandled error. Make __init__ for SimulateFieldStorageFromJson handle an empty json doc. Useful for POSTing to rest/data/class/@poe with an empty document. Testing: added testPostPOE to test/rest_common.py that I think covers all the code that was added. Documentation: Add doc to rest.txt in the "Client API" section titled: Safely Re-sending POST". Move existing section "Adding new rest endpoints" in "Client API" to a new second level section called "Programming the REST API". Also a minor change to the simple rest client moving the header setting to continuation lines rather than showing one long line.
1 parent 9e3b79e commit 20f9359

File tree

3 files changed

+520
-60
lines changed

3 files changed

+520
-60
lines changed

doc/rest.txt

Lines changed: 178 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,9 @@ Retire/Restore::
185185
>>> print("ETag: %s" % etag)
186186
>>> etag = r.json()['data']['@etag']
187187
>>> print("@etag: %s" % etag)
188-
>>> h = {'If-Match': etag, 'X-Requested-With': 'rest', 'Referer': 'http://tracker.example.com/demo/'}
188+
>>> h = {'If-Match': etag,
189+
... 'X-Requested-With': 'rest',
190+
... 'Referer': 'http://tracker.example.com/demo/'}
189191
>>> d = {'@op:'action', '@action_name':'retire'}
190192
>>> r = s.patch(u + 'issue/42', data = d, headers = h)
191193
>>> print(r.json())
@@ -195,82 +197,119 @@ Retire/Restore::
195197

196198
Note the addition of headers for: x-requested-with and referer. This
197199
allows the request to pass the CSRF protection mechanism. You may need
198-
to add Origin if this check is enabled in your tracker's config.ini.
200+
to add an Origin header if this check is enabled in your tracker's
201+
config.ini (look for csrf_enforce_header_origin).
199202

203+
Safely Re-sending POST
204+
======================
200205

201-
Adding new rest endpoints
202-
=========================
206+
POST is used to create new object in a class. E.G. a new issue. One
207+
problem is that a POST may time out. Because it is not idempotent like
208+
a PUT or DELETE, retrying the interrupted POST may result in the
209+
creation of a duplicate issue.
203210

204-
Add or edit the file interfaces.py at the root of the tracker
205-
directory.
211+
To solve this problem, a two step process inspired by the POE - Post
212+
Once Exactly spec:
213+
https://tools.ietf.org/html/draft-nottingham-http-poe-00 is provided.
206214

207-
In that file add::
215+
This mechanism returns a single use URL. POSTing to the URL creates
216+
a new object in the class.
208217

209-
from roundup.rest import Routing, RestfulInstance, _data_decorator
210-
from roundup.exceptions import Unauthorised
218+
First we get the URL. Here is an example using curl::
211219

212-
class RestfulInstance:
220+
curl -u demo:demo -s -X POST -H "Referer: https://.../demo/" \
221+
-H "X-requested-with: rest" \
222+
-H "Content-Type: application/json" \
223+
--data '' \
224+
https://.../demo/rest/data/issue/@poe
213225

214-
@Routing.route("/summary2")
215-
@_data_decorator
216-
def summary2(self, input):
217-
result = { "hello": "world" }
218-
return 200, result
226+
This will return a json payload like::
219227

220-
will make a new endpoint .../rest/summary2 that you can test with::
221-
222-
$ curl -X GET .../rest/summary2
223-
{
224-
"data": {
225-
"hello": "world"
226-
}
228+
{
229+
"data": {
230+
"expires": 1555266310.4457426,
231+
"link": "https://.../demo/rest/data/issue/@poe/vizl713xHtIzANRW9jPb3bWXePRzmehdmSXzEta1"
227232
}
233+
}
228234

229-
Similarly appending this to interfaces.py after summary2::
235+
The value of expires is a Unix timestamp in seconds. In this case it
236+
has the default lifetime of 30 minutes after the current time. Using
237+
the link more than 30 minutes into the future will cause a 400 error.
230238

231-
# handle more endpoints
232-
@Routing.route("/data/<:class_name>/@schema", 'GET')
233-
def get_element_schema(self, class_name, input):
234-
result = { "schema": {} }
235-
uid = self.db.getuid ()
236-
if not self.db.security.hasPermission('View', uid, class_name) :
237-
raise Unauthorised('Permission to view %s denied' % class_name)
239+
Within 30 minutes, the link can be used to post an issue with the same
240+
payload that would normally be sent to:
241+
``https://.../demo/rest/data/issue``.
238242

239-
class_obj = self.db.getclass(class_name)
240-
props = class_obj.getprops(protected=False)
241-
schema = result['schema']
243+
For example::
242244

243-
for prop in props:
244-
schema[prop] = { "type": repr(class_obj.properties[prop]) }
245+
curl -u demo:demo -s -X POST \
246+
-H "Referer: https://.../demo/" \
247+
-H "X-requested-with: rest" \
248+
-H "Content-Type: application/json" \
249+
--data-binary '{ "title": "a problem" }' \
250+
https://.../demo/rest/data/issue/@poe/vizl713xHtIzANRW9jPb3bWXePRzmehdmSXzEta1
245251

246-
return result
252+
returns::
247253

248-
..
249-
the # comment in the example is needed to preserve indention under Class.
254+
{
255+
"data": {
256+
"link": "https://.../demo/rest/data/issue/2280",
257+
"id": "2280"
258+
}
259+
}
250260

251-
returns some data about the class::
261+
Once the @poe link is used and creates an issue, it becomes invalid
262+
and can't be used again. Posting to it after the issue, or other
263+
object, is created, results in a 400 error [#poe_retry]_.
252264

253-
$ curl -X GET .../rest/data/issue/@schema
254-
{
255-
"schema": {
256-
"keyword": {
257-
"type": "<roundup.hyperdb.Multilink to \"keyword\">"
258-
},
259-
"title": {
260-
"type": "<roundup.hyperdb.String>"
261-
},
262-
"files": {
263-
"type": "<roundup.hyperdb.Multilink to \"file\">"
264-
},
265-
"status": {
266-
"type": "<roundup.hyperdb.Link to \"status\">"
267-
}, ...
268-
}
265+
Note that POE links are by restricted to the class that was used to
266+
get the link. So you can only create an issue using the link returned
267+
from ``rest/data/issue/@poe``. You can create a generic POE link by adding
268+
the "generic" field to the post payload::
269+
270+
curl -u demo:demo -s -X POST -H "Referer: https://.../demo/" \
271+
-H "X-requested-with: rest" \
272+
--data 'lifetime=100&generic=1' \
273+
https://.../demo/rest/data/issue/@poe
274+
275+
This will return a link under: ``https://.../demo/rest/data/issue/@poe``::
276+
277+
{
278+
"data": {
279+
"expires": 1555268640.9606116,
280+
"link":
281+
"https://.../demo/rest/data/issue/@poe/slPrzmEq6Q9BTjvcKhfxMNZL4uHXjbHCidY1ludZ"
269282
}
283+
}
270284

285+
You could use the link and change 'issue' to 'user' and it would work
286+
to create a user. Creating generic POE tokens is *not* recommended,
287+
but is available if a use case requires it.
271288

272-
Adding other endpoints (e.g. to allow an OPTIONS query against
273-
``/data/issue/@schema``) is left as an exercise for the reader.
289+
This example also changes the lifetime of the POE url. This link has
290+
a lifetime of 15 minutes (900 seconds). Using it after 16 minutes will
291+
result in a 400 error. A lifetime up to 1 hour can be specified.
292+
293+
POE url's are an optional mechanism. If:
294+
295+
* you do not expect your client to retry a failed post,
296+
* a failed post is unlikely (e.g. you are running over a local lan),
297+
* there is a human using the client and who can intervene if a post
298+
fails
299+
300+
you can use the url ``https://.../demo/data/<class>``. However if you
301+
are using this mechanism to automate creation of objects and will
302+
automatically retry a post until it succeeds, please use the POE
303+
mechanism.
304+
305+
.. [#poe_retry] At some future date, performing a POST to the POE link
306+
soon after it has been used to create an object will
307+
change. It will not return a 400 error. It will will trigger a
308+
redirect to the url for the created object. After some period
309+
of time (maybe a week) the POE link will be removed and return
310+
a 400 error. This is meant to allow the client (a time limited
311+
way) to retrieve the created resource when the response was
312+
lost.
274313

275314
Searches and selection
276315
======================
@@ -360,3 +399,85 @@ a select widget like::
360399

361400
etc. can be generated. Also depending on the javascript library, other
362401
fields can be used for subsearch and sorting.
402+
403+
404+
Programming the REST API
405+
------------------------
406+
407+
You can extend the rest api for a tracker. This describes how to add
408+
new rest end points. At some point it will also describe the rest.py
409+
structure and implementation.
410+
411+
Adding new rest endpoints
412+
=========================
413+
414+
Add or edit the file interfaces.py at the root of the tracker
415+
directory.
416+
417+
In that file add::
418+
419+
from roundup.rest import Routing, RestfulInstance, _data_decorator
420+
from roundup.exceptions import Unauthorised
421+
422+
class RestfulInstance:
423+
424+
@Routing.route("/summary2")
425+
@_data_decorator
426+
def summary2(self, input):
427+
result = { "hello": "world" }
428+
return 200, result
429+
430+
will make a new endpoint .../rest/summary2 that you can test with::
431+
432+
$ curl -X GET .../rest/summary2
433+
{
434+
"data": {
435+
"hello": "world"
436+
}
437+
}
438+
439+
Similarly appending this to interfaces.py after summary2::
440+
441+
# handle more endpoints
442+
@Routing.route("/data/<:class_name>/@schema", 'GET')
443+
def get_element_schema(self, class_name, input):
444+
result = { "schema": {} }
445+
uid = self.db.getuid ()
446+
if not self.db.security.hasPermission('View', uid, class_name) :
447+
raise Unauthorised('Permission to view %s denied' % class_name)
448+
449+
class_obj = self.db.getclass(class_name)
450+
props = class_obj.getprops(protected=False)
451+
schema = result['schema']
452+
453+
for prop in props:
454+
schema[prop] = { "type": repr(class_obj.properties[prop]) }
455+
456+
return result
457+
458+
..
459+
the # comment in the example is needed to preserve indention under Class.
460+
461+
returns some data about the class::
462+
463+
$ curl -X GET .../rest/data/issue/@schema
464+
{
465+
"schema": {
466+
"keyword": {
467+
"type": "<roundup.hyperdb.Multilink to \"keyword\">"
468+
},
469+
"title": {
470+
"type": "<roundup.hyperdb.String>"
471+
},
472+
"files": {
473+
"type": "<roundup.hyperdb.Multilink to \"file\">"
474+
},
475+
"status": {
476+
"type": "<roundup.hyperdb.Link to \"status\">"
477+
}, ...
478+
}
479+
}
480+
481+
482+
Adding other endpoints (e.g. to allow an OPTIONS query against
483+
``/data/issue/@schema``) is left as an exercise for the reader.

0 commit comments

Comments
 (0)