Mercurial > p > roundup > code
comparison roundup/rest.py @ 8177:2967f37e73e4
refactor: issue2551289. invalid REST Accept header stops request
Sending a POST, PUT (maybe PATCH) with an accept header that is not
application/json or xml (if enabled) used to complete the request
before throwing a 406 error. This was wrong.
Now it reports an error without dispatching/processing the requested
transaction. This is the first of a series of refactors of the
dispatch method to make it faster and more readable by using return
early pattern and extracting methods from the code.
changes:
The following now return 406 errors not 400 errors
invalid version specified with @apiver in URL.
invalid version specified with @apiver in payload body
invalid version specified in accept headers as
application/vnd.roundup.test-vz+json or version property
Parsing the accept header returns a 400 when presented with a
parameter without an = sign or other parse error. They used to
return a 406 which is wrong since the header is malformed rather
than having a value I can't respond to.
Some error messages were made clearer.
Results in the case of an error are proper json error object rather
than text/plain strings.
New test added for testdetermine_output_formatBadAccept that test the
new method using the same test cases as for
testDispatchBadAccept. I intend to extend the test coverage for
determine_output_format to cover more cases. This should be a faster
unit test than for dispatch.
Removed .lower() calls for accept_mime_type as the input values are
taken from the values in the __accepted_content_type dict which
only has lower case values.
| author | John Rouillard <rouilj@ieee.org> |
|---|---|
| date | Sun, 08 Dec 2024 01:09:34 -0500 |
| parents | f7bd22bdef9d |
| children | d02ce1d14acd |
comparison
equal
deleted
inserted
replaced
| 8176:736f769b48c8 | 8177:2967f37e73e4 |
|---|---|
| 265 It will actually convert the vendor and version into parameters and | 265 It will actually convert the vendor and version into parameters and |
| 266 convert the content type into `application/json` so appropriate content | 266 convert the content type into `application/json` so appropriate content |
| 267 negotiation decisions can be made. | 267 negotiation decisions can be made. |
| 268 | 268 |
| 269 Default `q` for values that are not specified is 1.0 | 269 Default `q` for values that are not specified is 1.0 |
| 270 | |
| 271 If q value > 1.0, it is parsed as a very small value. | |
| 270 | 272 |
| 271 # Based on https://gist.github.com/samuraisam/2714195 | 273 # Based on https://gist.github.com/samuraisam/2714195 |
| 272 # Also, based on a snipped found in this project: | 274 # Also, based on a snipped found in this project: |
| 273 # https://github.com/martinblech/mimerender | 275 # https://github.com/martinblech/mimerender |
| 274 """ | 276 """ |
| 2204 output, | 2206 output, |
| 2205 True # pretty print for this error case as a | 2207 True # pretty print for this error case as a |
| 2206 # human may read it | 2208 # human may read it |
| 2207 ), None) | 2209 ), None) |
| 2208 | 2210 |
| 2209 def dispatch(self, method, uri, input): | 2211 def determine_output_format(self, uri): |
| 2210 """format and process the request""" | 2212 """Returns tuple of: |
| 2211 output = None | 2213 |
| 2212 | 2214 (format for returned output, |
| 2213 # Before we do anything has the user hit the rate limit. | 2215 possibly modified uri, |
| 2214 | 2216 output error object (if error else None)) |
| 2215 # Get the limit here and not in the init() routine to allow | 2217 |
| 2216 # for a different rate limit per user. | 2218 Verify that client is requesting an output we can produce |
| 2217 apiRateLimit = self.getRateLimit() | 2219 with a version we support. |
| 2218 | 2220 |
| 2219 if apiRateLimit: # if None, disable rate limiting | 2221 Only application/json and application/xml (optional) |
| 2220 LimitExceeded, limitStatus = self.handle_apiRateLimitExceeded( | 2222 are currently supported. .vcard might be useful |
| 2221 apiRateLimit) | 2223 in the future for user objects. |
| 2222 if LimitExceeded: | 2224 |
| 2223 return LimitExceeded # error message | 2225 Find format for returned output: |
| 2224 | 2226 |
| 2225 for header, value in limitStatus.items(): | 2227 1) Get format from url extension (.json, .xml) and return |
| 2226 # Retry-After will be 0 because | 2228 if invalid return (None, uri, 406 error) |
| 2227 # user still has quota available. | 2229 if not found continue |
| 2228 # Don't put out the header. | 2230 2) Parse Accept header obeying q values |
| 2229 if header in ('Retry-After',): | 2231 if header unparsible return 400 error object. |
| 2230 continue | 2232 3) if empty or missing Accept header |
| 2231 self.client.setHeader(header, value) | 2233 return self.__default_accept_type |
| 2232 | 2234 4) match and return best Accept header/version |
| 2233 # if X-HTTP-Method-Override is set, follow the override method | 2235 if version error found in matching type return 406 error |
| 2234 headers = self.client.request.headers | 2236 5) if no requested format is supported return 406 |
| 2235 # Never allow GET to be an unsafe operation (i.e. data changing). | 2237 error |
| 2236 # User must use POST to "tunnel" DELETE, PUT, OPTIONS etc. | 2238 |
| 2237 override = headers.get('X-HTTP-Method-Override') | 2239 """ |
| 2238 if override: | 2240 # get the request format for response |
| 2239 if method.upper() == 'POST': | 2241 # priority : extension from uri (/rest/data/issue.json) |
| 2240 logger.debug( | 2242 # only json or xml valid at this time. |
| 2241 'Method overridden from %s to %s', method, override) | 2243 # header (Accept: application/json, application/xml) |
| 2242 method = override | 2244 # default (application/json) |
| 2243 else: | 2245 ext_type = os.path.splitext(urlparse(uri).path)[1][1:] |
| 2244 output = self.error_obj(400, | 2246 |
| 2245 "X-HTTP-Method-Override: %s must be used with " | 2247 # Check to see if the extension matches a value in |
| 2246 "POST method not %s." % (override, method.upper())) | 2248 # self.__accepted_content_type. In the future other output |
| 2247 logger.info( | 2249 # such as .vcard for downloading user info can be |
| 2248 'Ignoring X-HTTP-Method-Override using %s request on %s', | 2250 # supported. This method also allow detection of mistyped |
| 2249 method.upper(), uri) | 2251 # types like jon for json. Limit extension length to less than |
| 2252 # 10 characters to allow passing JWT via URL path. Could be | |
| 2253 # useful for magic link login method or account recovery workflow, | |
| 2254 # using a JWT with a short expiration time and limited rights | |
| 2255 # (e.g. only password change permission)) | |
| 2256 if ext_type and (len(ext_type) < 10): | |
| 2257 if ext_type not in list(self.__accepted_content_type.values()): | |
| 2258 self.client.response_code = 406 | |
| 2259 return (None, uri, | |
| 2260 self.error_obj( | |
| 2261 406, | |
| 2262 _("Content type '%s' requested in URL is " | |
| 2263 "not available.\nAcceptable types: %s\n") % | |
| 2264 (ext_type, | |
| 2265 ", ".join(sorted( | |
| 2266 set(self.__accepted_content_type.values())))))) | |
| 2267 | |
| 2268 # strip extension so uri makes sense. | |
| 2269 # E.G. .../issue.json -> .../issue | |
| 2270 uri = uri[:-(len(ext_type) + 1)] | |
| 2271 return (ext_type, uri, None) | |
| 2250 | 2272 |
| 2251 # parse Accept header and get the content type | 2273 # parse Accept header and get the content type |
| 2252 # Acceptable types ordered with preferred one first | 2274 # Acceptable types ordered with preferred one (by q value) |
| 2253 # in list. | 2275 # first in list. |
| 2254 try: | 2276 try: |
| 2255 accept_header = parse_accept_header(headers.get('Accept')) | 2277 accept_header = parse_accept_header( |
| 2278 self.client.request.headers.get('Accept') | |
| 2279 ) | |
| 2256 except UsageError as e: | 2280 except UsageError as e: |
| 2257 output = self.error_obj(406, _("Unable to parse Accept Header. %(error)s. " | 2281 self.client.response_code = 406 |
| 2258 "Acceptable types: %(acceptable_types)s") % { | 2282 return (None, uri, self.error_obj( |
| 2259 'error': e.args[0], | 2283 400, _("Unable to parse Accept Header. %(error)s. " |
| 2260 'acceptable_types': " ".join(sorted(self.__accepted_content_type.keys()))}) | 2284 "Acceptable types: %(acceptable_types)s") % { |
| 2261 accept_header = [] | 2285 'error': e.args[0], |
| 2286 'acceptable_types': " ".join(sorted( | |
| 2287 self.__accepted_content_type.keys()))})) | |
| 2262 | 2288 |
| 2263 if not accept_header: | 2289 if not accept_header: |
| 2264 accept_type = self.__default_accept_type | 2290 # we are using the default |
| 2265 else: | 2291 return (self.__default_accept_type, uri, None) |
| 2266 accept_type = None | 2292 |
| 2293 accept_type = "" | |
| 2267 for part in accept_header: | 2294 for part in accept_header: |
| 2268 if accept_type: | 2295 if accept_type: |
| 2269 # we accepted the best match, stop searching for | 2296 # we accepted the best match, stop searching for |
| 2270 # lower quality matches. | 2297 # lower quality matches. |
| 2271 break | 2298 break |
| 2285 try: | 2312 try: |
| 2286 self.api_version = int(part[1]['version']) | 2313 self.api_version = int(part[1]['version']) |
| 2287 except KeyError: | 2314 except KeyError: |
| 2288 self.api_version = None | 2315 self.api_version = None |
| 2289 except (ValueError, TypeError): | 2316 except (ValueError, TypeError): |
| 2317 self.client.response_code = 406 | |
| 2290 # TypeError if int(None) | 2318 # TypeError if int(None) |
| 2291 msg = ("Unrecognized api version: %s. " | 2319 msg = _("Unrecognized api version: %s. " |
| 2292 "See /rest without specifying api version " | 2320 "See /rest without specifying api version " |
| 2293 "for supported versions." % ( | 2321 "for supported versions.") % ( |
| 2294 part[1]['version'])) | 2322 part[1]['version']) |
| 2295 output = self.error_obj(400, msg) | 2323 return (None, uri, |
| 2296 | 2324 self.error_obj(406, msg)) |
| 2297 # get the request format for response | 2325 |
| 2298 # priority : extension from uri (/rest/data/issue.json), | 2326 # accept_type will be empty only if there is an Accept header |
| 2299 # header (Accept: application/json, application/xml) | |
| 2300 # default (application/json) | |
| 2301 ext_type = os.path.splitext(urlparse(uri).path)[1][1:] | |
| 2302 | |
| 2303 # Check to see if the length of the extension is less than 6. | |
| 2304 # this allows use of .vcard for a future use in downloading | |
| 2305 # user info. It also allows passing through larger items like | |
| 2306 # JWT that has a final component > 6 items. This method also | |
| 2307 # allow detection of mistyped types like jon for json. | |
| 2308 if ext_type and (len(ext_type) < 6): | |
| 2309 # strip extension so uri make sense | |
| 2310 # .../issue.json -> .../issue | |
| 2311 uri = uri[:-(len(ext_type) + 1)] | |
| 2312 else: | |
| 2313 ext_type = None | |
| 2314 | |
| 2315 # headers.get('Accept') is never empty if called here. | |
| 2316 # accept_type will be set to json if there is no Accept header | |
| 2317 # accept_type wil be empty only if there is an Accept header | |
| 2318 # with invalid values. | 2327 # with invalid values. |
| 2319 data_type = ext_type or accept_type or headers.get('Accept') or "invalid" | 2328 if accept_type: |
| 2329 return (accept_type, uri, None) | |
| 2330 | |
| 2331 self.client.response_code = 400 | |
| 2332 return (None, uri, | |
| 2333 self.error_obj( | |
| 2334 406, | |
| 2335 _("Requested content type(s) '%s' not available.\n" | |
| 2336 "Acceptable mime types are: %s") % | |
| 2337 (self.client.request.headers.get('Accept'), | |
| 2338 ", ".join(sorted( | |
| 2339 self.__accepted_content_type.keys()))))) | |
| 2340 | |
| 2341 def dispatch(self, method, uri, input): | |
| 2342 """format and process the request""" | |
| 2343 output = None | |
| 2344 | |
| 2345 # Before we do anything has the user hit the rate limit. | |
| 2346 | |
| 2347 # Get the limit here and not in the init() routine to allow | |
| 2348 # for a different rate limit per user. | |
| 2349 apiRateLimit = self.getRateLimit() | |
| 2350 | |
| 2351 if apiRateLimit: # if None, disable rate limiting | |
| 2352 LimitExceeded, limitStatus = self.handle_apiRateLimitExceeded( | |
| 2353 apiRateLimit) | |
| 2354 if LimitExceeded: | |
| 2355 return LimitExceeded # error message | |
| 2356 | |
| 2357 for header, value in limitStatus.items(): | |
| 2358 # Retry-After will be 0 because | |
| 2359 # user still has quota available. | |
| 2360 # Don't put out the header. | |
| 2361 if header in ('Retry-After',): | |
| 2362 continue | |
| 2363 self.client.setHeader(header, value) | |
| 2364 | |
| 2365 # if X-HTTP-Method-Override is set, follow the override method | |
| 2366 headers = self.client.request.headers | |
| 2367 # Never allow GET to be an unsafe operation (i.e. data changing). | |
| 2368 # User must use POST to "tunnel" DELETE, PUT, OPTIONS etc. | |
| 2369 override = headers.get('X-HTTP-Method-Override') | |
| 2370 if override: | |
| 2371 if method.upper() == 'POST': | |
| 2372 logger.debug( | |
| 2373 'Method overridden from %s to %s', method, override) | |
| 2374 method = override | |
| 2375 else: | |
| 2376 output = self.error_obj(400, | |
| 2377 "X-HTTP-Method-Override: %s must be used with " | |
| 2378 "POST method not %s." % (override, method.upper())) | |
| 2379 logger.info( | |
| 2380 'Ignoring X-HTTP-Method-Override using %s request on %s', | |
| 2381 method.upper(), uri) | |
| 2382 | |
| 2383 # FIXME: when this method is refactored, change | |
| 2384 # determine_output_format to raise an exception. Catch it here | |
| 2385 # and return early. Also set self.client.response_code from | |
| 2386 # error object['error']['status'] and remove from | |
| 2387 # determine_output_format. | |
| 2388 (output_format, uri, error) = self.determine_output_format(uri) | |
| 2389 if error: | |
| 2390 output = error | |
| 2391 | |
| 2320 if method.upper() == 'OPTIONS': | 2392 if method.upper() == 'OPTIONS': |
| 2321 # add access-control-allow-* access-control-max-age to support | 2393 # add access-control-allow-* access-control-max-age to support |
| 2322 # CORS preflight | 2394 # CORS preflight |
| 2323 self.client.setHeader( | 2395 self.client.setHeader( |
| 2324 "Access-Control-Allow-Headers", | 2396 "Access-Control-Allow-Headers", |
| 2439 # check for @apiver in query string | 2511 # check for @apiver in query string |
| 2440 msg = _("Unrecognized api version: %s. " | 2512 msg = _("Unrecognized api version: %s. " |
| 2441 "See /rest without specifying api version " | 2513 "See /rest without specifying api version " |
| 2442 "for supported versions.") | 2514 "for supported versions.") |
| 2443 try: | 2515 try: |
| 2516 # FIXME: the version priority here is different | |
| 2517 # from accept header. accept mime type in url | |
| 2518 # takes priority over Accept header. Opposite here. | |
| 2444 if not self.api_version: | 2519 if not self.api_version: |
| 2445 self.api_version = int(input['@apiver'].value) | 2520 self.api_version = int(input['@apiver'].value) |
| 2446 # Can also return a TypeError ("not indexable") | 2521 # Can also return a TypeError ("not indexable") |
| 2447 # In case the FieldStorage could not parse the result | 2522 # In case the FieldStorage could not parse the result |
| 2448 except (KeyError, TypeError): | 2523 except (KeyError, TypeError): |
| 2449 self.api_version = None | 2524 self.api_version = None |
| 2450 except ValueError: | 2525 except ValueError: |
| 2451 output = self.error_obj(400, msg % input['@apiver'].value) | 2526 output = self.error_obj(406, msg % input['@apiver'].value) |
| 2452 | 2527 |
| 2453 # by this time the API version is set. Error if we don't | 2528 # by this time the API version is set. Error if we don't |
| 2454 # support it? | 2529 # support it? |
| 2455 if self.api_version is None: | 2530 if self.api_version is None: |
| 2456 # FIXME: do we need to raise an error if client did not specify | 2531 # FIXME: do we need to raise an error if client did not specify |
| 2457 # version? This may be a good thing to require. Note that: | 2532 # version? This may be a good thing to require. Note that: |
| 2458 # Accept: application/json; version=1 may not be legal but.... | 2533 # Accept: application/json; version=1 may not be legal but.... |
| 2459 # Use default if not specified for now. | 2534 # Use default if not specified for now. |
| 2460 self.api_version = self.__default_api_version | 2535 self.api_version = self.__default_api_version |
| 2461 elif self.api_version not in self.__supported_api_versions: | 2536 elif self.api_version not in self.__supported_api_versions: |
| 2462 output = self.error_obj(400, msg % self.api_version) | 2537 output = self.error_obj(406, msg % self.api_version) |
| 2463 | 2538 |
| 2464 # sadly del doesn't work on FieldStorage which can be the type of | 2539 # sadly del doesn't work on FieldStorage which can be the type of |
| 2465 # input. So we have to ignore keys starting with @ at other | 2540 # input. So we have to ignore keys starting with @ at other |
| 2466 # places in the code. | 2541 # places in the code. |
| 2467 # else: | 2542 # else: |
| 2477 output = self.error_obj(404, msg) | 2552 output = self.error_obj(404, msg) |
| 2478 except Reject as msg: | 2553 except Reject as msg: |
| 2479 output = self.error_obj(405, msg.args[0]) | 2554 output = self.error_obj(405, msg.args[0]) |
| 2480 self.client.setHeader("Allow", msg.args[1]) | 2555 self.client.setHeader("Allow", msg.args[1]) |
| 2481 | 2556 |
| 2482 return self.format_dispatch_output(data_type, output, pretty_output) | 2557 return self.format_dispatch_output(output_format, |
| 2558 output, | |
| 2559 pretty_output) | |
| 2483 | 2560 |
| 2484 def format_dispatch_output(self, accept_mime_type, output, | 2561 def format_dispatch_output(self, accept_mime_type, output, |
| 2485 pretty_print=True): | 2562 pretty_print=True): |
| 2486 # Format the content type | 2563 # Format the content type |
| 2487 if accept_mime_type.lower() == "json": | 2564 # if accept_mime_type is None, the client specified invalid |
| 2565 # mime types so we default to json output. | |
| 2566 if accept_mime_type == "json" or accept_mime_type is None: | |
| 2488 self.client.setHeader("Content-Type", "application/json") | 2567 self.client.setHeader("Content-Type", "application/json") |
| 2489 if pretty_print: | 2568 if pretty_print: |
| 2490 indent = 4 | 2569 indent = 4 |
| 2491 else: | 2570 else: |
| 2492 indent = None | 2571 indent = None |
| 2493 output = RoundupJSONEncoder(indent=indent).encode(output) | 2572 output = RoundupJSONEncoder(indent=indent).encode(output) |
| 2494 elif accept_mime_type.lower() == "xml" and dicttoxml: | 2573 elif accept_mime_type == "xml" and dicttoxml: |
| 2495 self.client.setHeader("Content-Type", "application/xml") | 2574 self.client.setHeader("Content-Type", "application/xml") |
| 2496 if 'error' in output: | 2575 if 'error' in output: |
| 2497 # capture values in error with types unsupported | 2576 # capture values in error with types unsupported |
| 2498 # by dicttoxml e.g. an exception, into something it | 2577 # by dicttoxml e.g. an exception, into something it |
| 2499 # can handle | 2578 # can handle |
