Skip to content

Commit c309797

Browse files
committed
Fix uncaught error when parsing rest headers, document
Started this work as better docs for rest response format. But I found 406 error response was not being tested. Also there was no error for bad Content-Type. In rest.py fix uncaught exceptions due to invalid Accept or Content-Type headers. If Content-type is valid but not application/json return code 415. Document use of accept header (was only shown in examples) and support for q parameter. Describe using .xml and .json extensions to select return format for testing from browser (where setting accept header is a problem). Document 406 error code return. Document 415 error code return and acceptable content types. Previously only doc was in examples. Set up tests for 406 and 415 error codes.
1 parent d80e3f4 commit c309797

File tree

4 files changed

+260
-10
lines changed

4 files changed

+260
-10
lines changed

CHANGES.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ Fixed:
6363
- fixed some issues when generating translations. Use mappings and
6464
named format parameters so translators can move substituted tokens
6565
in tranlsations.
66+
- in rest interface, fix uncaught exceptions when parsing invalid
67+
Content-Type and Accept headers. Document response formats more
68+
fully in doc/rest.txt.
6669

6770
Features:
6871
- issue2550522 - Add 'filter' command to command-line

doc/rest.txt

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,36 @@ If an explicit version is not provided, the server default is used.
187187
The server default is reported by querying the ``/rest/`` endpoint as
188188
described above.
189189

190+
Input Formats
191+
=============
192+
193+
Content-Type is allowed to be ``application/json`` or
194+
``application/x-www-form-urlencoded``. Any other value returns error
195+
code 415.
196+
197+
Response Formats
198+
================
199+
200+
The default response format is json.
201+
202+
If you add the ``dicttoxml.py`` module you can request XML formatted
203+
data using the header ``Accept: application/xml`` in your
204+
request. Both output formats are similar in structure.
205+
206+
The rest interface accepts the http accept header and can include
207+
``q`` values to specify the preferred mechanism. This is the preferred
208+
way to specify alternate acceptable response formats.
209+
210+
To make testing from the browser easier, you can also append the
211+
extension `.json` or `.xml` to the path component of the url. This
212+
will force json or xml (if supported) output. If you use an extension
213+
it takes priority over any accept headers.
214+
215+
The rest interface returns status 406 if you use an unrecognized
216+
extension. You will also get a 406 status if none of the entries in
217+
the accept header are available or if the accept header is invalid.
218+
219+
190220
General Guidelines
191221
==================
192222

@@ -200,12 +230,8 @@ The exact details of returned data is determined by the value of the
200230
``@verbose`` query parameter. The various supported values and their
201231
effects are described in the following sections.
202232

203-
The default return format is JSON. If you add the ``dicttoxml.py``
204-
module you can request XML formatted data using the header ``Accept:
205-
application/xml`` in your request. Both output formats are similar in
206-
structure.
207-
208-
All output is wrapped in an envelope called ``data``.
233+
All output is wrapped in an envelope called ``data``. The output
234+
format is described in `Response Formats`_ above.
209235

210236
When using collection endpoints (think list of issues, users ...), the
211237
``data`` envelope contains metadata (e.g. total number of items) as

roundup/rest.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,10 @@ def parse_accept_header(accept):
223223
media_params = []
224224
# convert vendor-specific content types into something useful (see
225225
# docstring)
226-
typ, subtyp = media_type.split('/')
226+
try:
227+
typ, subtyp = media_type.split('/')
228+
except ValueError:
229+
raise UsageError("Invalid media type: %s"%media_type)
227230
# check for a + in the sub-type
228231
if '+' in subtyp:
229232
# if it exists, determine if the subtype is a vendor-specific type
@@ -246,7 +249,10 @@ def parse_accept_header(accept):
246249
media_type = '{}/{}'.format(typ, extra)
247250
q = 1.0
248251
for part in parts:
249-
(key, value) = part.lstrip().split("=", 1)
252+
try:
253+
(key, value) = part.lstrip().split("=", 1)
254+
except ValueError:
255+
raise UsageError("Invalid param: %s"%part.lstrip())
250256
key = key.strip()
251257
value = value.strip()
252258
if key == "q":
@@ -1873,7 +1879,15 @@ def dispatch(self, method, uri, input):
18731879
# parse Accept header and get the content type
18741880
# Acceptable types ordered with preferred one first
18751881
# in list.
1876-
accept_header = parse_accept_header(headers.get('Accept'))
1882+
try:
1883+
accept_header = parse_accept_header(headers.get('Accept'))
1884+
except UsageError as e:
1885+
output = self.error_obj(406, _("Unable to parse Accept Header. %(error)s. "
1886+
"Acceptable types: %(acceptable_types)s") % {
1887+
'error': e.args[0],
1888+
'acceptable_types': " ".join(sorted(self.__accepted_content_type.keys()))})
1889+
accept_header = []
1890+
18771891
if not accept_header:
18781892
accept_type = self.__default_accept_type
18791893
else:
@@ -1913,7 +1927,12 @@ def dispatch(self, method, uri, input):
19131927
# header (Accept: application/json, application/xml)
19141928
# default (application/json)
19151929
ext_type = os.path.splitext(urlparse(uri).path)[1][1:]
1916-
data_type = ext_type or accept_type or "invalid"
1930+
1931+
# headers.get('Accept') is never empty if called here.
1932+
# accept_type will be set to json if there is no Accept header
1933+
# accept_type wil be empty only if there is an Accept header
1934+
# with invalid values.
1935+
data_type = ext_type or accept_type or headers.get('Accept') or "invalid"
19171936

19181937
if (ext_type):
19191938
# strip extension so uri make sense
@@ -1956,6 +1975,10 @@ def dispatch(self, method, uri, input):
19561975
input = SimulateFieldStorageFromJson(b2s(input.value))
19571976
except ValueError as msg:
19581977
output = self.error_obj(400, msg)
1978+
else:
1979+
output = self.error_obj(415,
1980+
"Unable to process input of type %s" %
1981+
content_type_header)
19591982

19601983
# check for pretty print
19611984
try:

test/rest_common.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1321,6 +1321,204 @@ def testDispatchPost(self):
13211321
['assignedto']['link'],
13221322
"http://tracker.example/cgi-bin/roundup.cgi/bugs/rest/data/user/2")
13231323

1324+
def testDispatchBadContent(self):
1325+
"""
1326+
runthrough rest dispatch() with bad content_type patterns.
1327+
"""
1328+
1329+
# simulate: /rest/data/issue
1330+
body=b'{ "title": "Joe Doe has problems", \
1331+
"nosy": [ "1", "3" ], \
1332+
"assignedto": "2", \
1333+
"abool": true, \
1334+
"afloat": 2.3, \
1335+
"anint": 567890 \
1336+
}'
1337+
env = { "CONTENT_TYPE": "application/jzot",
1338+
"CONTENT_LENGTH": len(body),
1339+
"REQUEST_METHOD": "POST"
1340+
}
1341+
1342+
headers={"accept": "application/json; version=1",
1343+
"content-type": env['CONTENT_TYPE'],
1344+
"content-length": env['CONTENT_LENGTH'],
1345+
}
1346+
1347+
self.headers=headers
1348+
# we need to generate a FieldStorage the looks like
1349+
# FieldStorage(None, None, 'string') rather than
1350+
# FieldStorage(None, None, [])
1351+
body_file=BytesIO(body) # FieldStorage needs a file
1352+
form = client.BinaryFieldStorage(body_file,
1353+
headers=headers,
1354+
environ=env)
1355+
self.server.client.request.headers.get=self.get_header
1356+
results = self.server.dispatch(env["REQUEST_METHOD"],
1357+
"/rest/data/issue",
1358+
form)
1359+
1360+
print(results)
1361+
self.assertEqual(self.server.client.response_code, 415)
1362+
json_dict = json.loads(b2s(results))
1363+
self.assertEqual(json_dict['error']['msg'],
1364+
"Unable to process input of type application/jzot")
1365+
1366+
# Test GET as well. I am not sure if this should pass or not.
1367+
# Arguably GET doesn't use any form/json input but....
1368+
results = self.server.dispatch('GET',
1369+
"/rest/data/issue",
1370+
form)
1371+
print(results)
1372+
self.assertEqual(self.server.client.response_code, 415)
1373+
1374+
1375+
1376+
def testDispatchBadAccept(self):
1377+
# simulate: /rest/data/issue expect failure unknown accept settings
1378+
body=b'{ "title": "Joe Doe has problems", \
1379+
"nosy": [ "1", "3" ], \
1380+
"assignedto": "2", \
1381+
"abool": true, \
1382+
"afloat": 2.3, \
1383+
"anint": 567890 \
1384+
}'
1385+
env = { "CONTENT_TYPE": "application/json",
1386+
"CONTENT_LENGTH": len(body),
1387+
"REQUEST_METHOD": "POST"
1388+
}
1389+
1390+
headers={"accept": "application/zot; version=1; q=0.5",
1391+
"content-type": env['CONTENT_TYPE'],
1392+
"content-length": env['CONTENT_LENGTH'],
1393+
}
1394+
1395+
self.headers=headers
1396+
# we need to generate a FieldStorage the looks like
1397+
# FieldStorage(None, None, 'string') rather than
1398+
# FieldStorage(None, None, [])
1399+
body_file=BytesIO(body) # FieldStorage needs a file
1400+
form = client.BinaryFieldStorage(body_file,
1401+
headers=headers,
1402+
environ=env)
1403+
self.server.client.request.headers.get=self.get_header
1404+
results = self.server.dispatch(env["REQUEST_METHOD"],
1405+
"/rest/data/issue",
1406+
form)
1407+
1408+
print(results)
1409+
self.assertEqual(self.server.client.response_code, 406)
1410+
self.assertIn(b"Requested content type 'application/zot; version=1; q=0.5' is not available.\nAcceptable types: */*, application/json,", results)
1411+
1412+
# simulate: /rest/data/issue works, multiple acceptable output, one
1413+
# is valid
1414+
env = { "CONTENT_TYPE": "application/json",
1415+
"CONTENT_LENGTH": len(body),
1416+
"REQUEST_METHOD": "POST"
1417+
}
1418+
1419+
headers={"accept": "application/zot; version=1; q=0.75, "
1420+
"application/json; version=1; q=0.5",
1421+
"content-type": env['CONTENT_TYPE'],
1422+
"content-length": env['CONTENT_LENGTH'],
1423+
}
1424+
1425+
self.headers=headers
1426+
# we need to generate a FieldStorage the looks like
1427+
# FieldStorage(None, None, 'string') rather than
1428+
# FieldStorage(None, None, [])
1429+
body_file=BytesIO(body) # FieldStorage needs a file
1430+
form = client.BinaryFieldStorage(body_file,
1431+
headers=headers,
1432+
environ=env)
1433+
self.server.client.request.headers.get=self.get_header
1434+
results = self.server.dispatch(env["REQUEST_METHOD"],
1435+
"/rest/data/issue",
1436+
form)
1437+
1438+
print(results)
1439+
self.assertEqual(self.server.client.response_code, 201)
1440+
json_dict = json.loads(b2s(results))
1441+
# ERROR this should be 1. What's happening is that the code
1442+
# for handling 406 error code runs through everything and creates
1443+
# the item. Then it throws a 406 after the work is done when it
1444+
# realizes it can't respond as requested. So the 406 post above
1445+
# creates issue 1 and this one creates issue 2.
1446+
self.assertEqual(json_dict['data']['id'], "2")
1447+
1448+
1449+
# test 3 accept is empty. This triggers */* so passes
1450+
headers={"accept": "",
1451+
"content-type": env['CONTENT_TYPE'],
1452+
"content-length": env['CONTENT_LENGTH'],
1453+
}
1454+
1455+
self.headers=headers
1456+
# we need to generate a FieldStorage the looks like
1457+
# FieldStorage(None, None, 'string') rather than
1458+
# FieldStorage(None, None, [])
1459+
body_file=BytesIO(body) # FieldStorage needs a file
1460+
form = client.BinaryFieldStorage(body_file,
1461+
headers=headers,
1462+
environ=env)
1463+
self.server.client.request.headers.get=self.get_header
1464+
results = self.server.dispatch(env["REQUEST_METHOD"],
1465+
"/rest/data/issue",
1466+
form)
1467+
1468+
print(results)
1469+
self.assertEqual(self.server.client.response_code, 201)
1470+
json_dict = json.loads(b2s(results))
1471+
# This is one more than above. Will need to be fixed
1472+
# When error above is fixed.
1473+
self.assertEqual(json_dict['data']['id'], "3")
1474+
1475+
# test 4 accept is random junk.
1476+
headers={"accept": "Xyzzy I am not a mime, type;",
1477+
"content-type": env['CONTENT_TYPE'],
1478+
"content-length": env['CONTENT_LENGTH'],
1479+
}
1480+
1481+
self.headers=headers
1482+
# we need to generate a FieldStorage the looks like
1483+
# FieldStorage(None, None, 'string') rather than
1484+
# FieldStorage(None, None, [])
1485+
body_file=BytesIO(body) # FieldStorage needs a file
1486+
form = client.BinaryFieldStorage(body_file,
1487+
headers=headers,
1488+
environ=env)
1489+
self.server.client.request.headers.get=self.get_header
1490+
results = self.server.dispatch(env["REQUEST_METHOD"],
1491+
"/rest/data/issue",
1492+
form)
1493+
1494+
print(results)
1495+
self.assertEqual(self.server.client.response_code, 406)
1496+
json_dict = json.loads(b2s(results))
1497+
self.assertIn('Unable to parse Accept Header. Invalid media type: Xyzzy I am not a mime. Acceptable types: */* application/json', json_dict['error']['msg'])
1498+
1499+
# test 5 accept mimetype is ok, param is not
1500+
headers={"accept": "*/*; foo",
1501+
"content-type": env['CONTENT_TYPE'],
1502+
"content-length": env['CONTENT_LENGTH'],
1503+
}
1504+
1505+
self.headers=headers
1506+
# we need to generate a FieldStorage the looks like
1507+
# FieldStorage(None, None, 'string') rather than
1508+
# FieldStorage(None, None, [])
1509+
body_file=BytesIO(body) # FieldStorage needs a file
1510+
form = client.BinaryFieldStorage(body_file,
1511+
headers=headers,
1512+
environ=env)
1513+
self.server.client.request.headers.get=self.get_header
1514+
results = self.server.dispatch(env["REQUEST_METHOD"],
1515+
"/rest/data/issue",
1516+
form)
1517+
1518+
print(results)
1519+
self.assertEqual(self.server.client.response_code, 406)
1520+
json_dict = json.loads(b2s(results))
1521+
self.assertIn('Unable to parse Accept Header. Invalid param: foo. Acceptable types: */* application/json', json_dict['error']['msg'])
13241522

13251523
def testStatsGen(self):
13261524
# check stats being returned by put and get ops

0 commit comments

Comments
 (0)