comparison roundup/rest.py @ 8180:d02ce1d14acd

feat: issue2551068 - Provide way to retrieve file/msg data via rest endpoint. Use Allow header to change format of /binary_content endpoint. If Allow header for endpoint is not application/json, it will be matched against the mime type for the file. */*, text/* are supported and will return the native mime type if present. Changes: move */* mime type from static dict of supported types. It was hardcoded to return json only. Now it can return a matching non-json mime type for the /binary_content endpoint. Edited some errors to explicitly add */* mime type. Cleanups to use ', ' separation in lists of valid mime types rather than just space separated. Remove ETag header when sending raw content. See issue 2551375 for background. Doc added to rest.txt. Small format fix up (add dash) in CHANGES.txt. Make passing an unset/None/False accept_mime_type to format_dispatch_output a 500 error. This used to be the fallback to produce a 406 error after all processing had happened. It should no longer be possible to take that code path as all 406 errors (with valid accept_mime_types) are generated before processing takes place. Make format_dispatch_output handle output other than json/xml so it can send back binary_content data. Removed a spurious client.response_code = 400 that seems to not be used. Tests added for all code paths. Database setup for tests msg and file entry. This required a file upload test to change so it doesn't look for file1 as the link returned by the upload. Download the link and verify the data rather than verifying the link. Multiple formatting changes to error messages to make all lists of valid mime types ', ' an not just space separated.
author John Rouillard <rouilj@ieee.org>
date Sun, 08 Dec 2024 17:22:33 -0500
parents 2967f37e73e4
children e5362f8e1808
comparison
equal deleted inserted replaced
8179:53afc3ffd4a2 8180:d02ce1d14acd
431 """The RestfulInstance performs REST request from the client""" 431 """The RestfulInstance performs REST request from the client"""
432 432
433 __default_patch_op = "replace" # default operator for PATCH method 433 __default_patch_op = "replace" # default operator for PATCH method
434 __accepted_content_type = { 434 __accepted_content_type = {
435 "application/json": "json", 435 "application/json": "json",
436 "*/*": "json",
437 } 436 }
438 __default_accept_type = "json" 437 __default_accept_type = "json"
439 438
440 __default_api_version = 1 439 __default_api_version = 1
441 __supported_api_versions = [1] 440 __supported_api_versions = [1]
2230 2) Parse Accept header obeying q values 2229 2) Parse Accept header obeying q values
2231 if header unparsible return 400 error object. 2230 if header unparsible return 400 error object.
2232 3) if empty or missing Accept header 2231 3) if empty or missing Accept header
2233 return self.__default_accept_type 2232 return self.__default_accept_type
2234 4) match and return best Accept header/version 2233 4) match and return best Accept header/version
2234 this includes matching mime types for file downloads
2235 using the binary_content property
2235 if version error found in matching type return 406 error 2236 if version error found in matching type return 406 error
2236 5) if no requested format is supported return 406 2237 5) if no requested format is supported return 406
2237 error 2238 error
2238 2239
2239 """ 2240 """
2279 ) 2280 )
2280 except UsageError as e: 2281 except UsageError as e:
2281 self.client.response_code = 406 2282 self.client.response_code = 406
2282 return (None, uri, self.error_obj( 2283 return (None, uri, self.error_obj(
2283 400, _("Unable to parse Accept Header. %(error)s. " 2284 400, _("Unable to parse Accept Header. %(error)s. "
2284 "Acceptable types: %(acceptable_types)s") % { 2285 "Acceptable types: */*, %(acceptable_types)s") % {
2285 'error': e.args[0], 2286 'error': e.args[0],
2286 'acceptable_types': " ".join(sorted( 2287 'acceptable_types': ", ".join(sorted(
2287 self.__accepted_content_type.keys()))})) 2288 self.__accepted_content_type.keys()))}))
2288 2289
2289 if not accept_header: 2290 if not accept_header:
2290 # we are using the default 2291 # we are using the default
2291 return (self.__default_accept_type, uri, None) 2292 return (self.__default_accept_type, uri, None)
2292 2293
2293 accept_type = "" 2294 accept_type = ""
2295 valid_binary_content_types = []
2296 if uri.endswith("/binary_content"):
2297 request_path = uri
2298 request_class, request_id = request_path.split('/')[-3:-1]
2299 try:
2300 designator_type = self.db.getclass(
2301 request_class).get(request_id, "type")
2302 except (KeyError, IndexError):
2303 # class (KeyError) or
2304 # id (IndexError) does not exist
2305 # Return unknown mime type and no error.
2306 # The 400/404 error will be thrown by other code.
2307 return (None, uri, None)
2308
2309 if designator_type:
2310 # put this first as we usually require exact mime
2311 # type match and this will be matched most often.
2312 # Also for text/* Accept header it will be returned.
2313 valid_binary_content_types.append(designator_type)
2314
2315 if not designator_type or designator_type.startswith('text/'):
2316 # allow text/* as msg items can have empty type field
2317 # also match text/* for text/plain, text/x-rst,
2318 # text/markdown, text/html etc.
2319 valid_binary_content_types.append("text/*")
2320
2321 # Octet-stream should be allowed for any content.
2322 # client.py sets 'X-Content-Type-Options: nosniff'
2323 # for file downloads (sendfile) via the html interface,
2324 # so we should be able to set it in this code as well.
2325 valid_binary_content_types.append("application/octet-stream")
2326
2294 for part in accept_header: 2327 for part in accept_header:
2295 if accept_type: 2328 if accept_type:
2296 # we accepted the best match, stop searching for 2329 # we accepted the best match, stop searching for
2297 # lower quality matches. 2330 # lower quality matches.
2298 break 2331 break
2332
2333 # check for structured rest return types (json xml)
2299 if part[0] in self.__accepted_content_type: 2334 if part[0] in self.__accepted_content_type:
2300 accept_type = self.__accepted_content_type[part[0]] 2335 accept_type = self.__accepted_content_type[part[0]]
2301 # Version order: 2336 # Version order:
2302 # 1) accept header version=X specifier 2337 # 1) accept header version=X specifier
2303 # application/vnd.x.y; version=1 2338 # application/vnd.x.y; version=1
2309 # to trigger @apiver parsing below 2344 # to trigger @apiver parsing below
2310 # Places that need the api_version info should 2345 # Places that need the api_version info should
2311 # use default if version = None 2346 # use default if version = None
2312 try: 2347 try:
2313 self.api_version = int(part[1]['version']) 2348 self.api_version = int(part[1]['version'])
2349 if self.api_version not in self.__supported_api_versions:
2350 raise ValueError
2314 except KeyError: 2351 except KeyError:
2315 self.api_version = None 2352 self.api_version = None
2316 except (ValueError, TypeError): 2353 except (ValueError, TypeError):
2317 self.client.response_code = 406 2354 self.client.response_code = 406
2318 # TypeError if int(None) 2355 # TypeError if int(None)
2321 "for supported versions.") % ( 2358 "for supported versions.") % (
2322 part[1]['version']) 2359 part[1]['version'])
2323 return (None, uri, 2360 return (None, uri,
2324 self.error_obj(406, msg)) 2361 self.error_obj(406, msg))
2325 2362
2363 if part[0] == "*/*":
2364 if valid_binary_content_types:
2365 self.client.setHeader("X-Content-Type-Options", "nosniff")
2366 accept_type = valid_binary_content_types[0]
2367 else:
2368 accept_type = "json"
2369
2370 # check type of binary_content
2371 if part[0] in valid_binary_content_types:
2372 self.client.setHeader("X-Content-Type-Options", "nosniff")
2373 accept_type = part[0]
2374 # handle text wildcard
2375 if ((part[0] in 'text/*') and
2376 "text/*" in valid_binary_content_types):
2377 self.client.setHeader("X-Content-Type-Options", "nosniff")
2378 # use best choice of mime type, try not to use
2379 # text/* if there is a real text mime type/subtype.
2380 accept_type = valid_binary_content_types[0]
2381
2326 # accept_type will be empty only if there is an Accept header 2382 # accept_type will be empty only if there is an Accept header
2327 # with invalid values. 2383 # with invalid values.
2328 if accept_type: 2384 if accept_type:
2329 return (accept_type, uri, None) 2385 return (accept_type, uri, None)
2330 2386
2331 self.client.response_code = 400 2387 if valid_binary_content_types:
2388 return (None, uri,
2389 self.error_obj(
2390 406,
2391 _("Requested content type(s) '%s' not available.\n"
2392 "Acceptable mime types are: */*, %s") %
2393 (self.client.request.headers.get('Accept'),
2394 ", ".join(sorted(
2395 valid_binary_content_types)))))
2396
2332 return (None, uri, 2397 return (None, uri,
2333 self.error_obj( 2398 self.error_obj(
2334 406, 2399 406,
2335 _("Requested content type(s) '%s' not available.\n" 2400 _("Requested content type(s) '%s' not available.\n"
2336 "Acceptable mime types are: %s") % 2401 "Acceptable mime types are: */*, %s") %
2337 (self.client.request.headers.get('Accept'), 2402 (self.client.request.headers.get('Accept'),
2338 ", ".join(sorted( 2403 ", ".join(sorted(
2339 self.__accepted_content_type.keys()))))) 2404 self.__accepted_content_type.keys())))))
2340 2405
2341 def dispatch(self, method, uri, input): 2406 def dispatch(self, method, uri, input):
2595 else: 2660 else:
2596 output['error'][key] = str(val) 2661 output['error'][key] = str(val)
2597 2662
2598 output = '<?xml version="1.0" encoding="UTF-8" ?>\n' + \ 2663 output = '<?xml version="1.0" encoding="UTF-8" ?>\n' + \
2599 b2s(dicttoxml(output, root=False)) 2664 b2s(dicttoxml(output, root=False))
2665 elif accept_mime_type:
2666 self.client.setHeader("Content-Type", accept_mime_type)
2667 # do not send etag when getting binary_content. The ETag
2668 # is for the item not the content of the item. So the ETag
2669 # can change even though the content is the same. Since
2670 # content is immutable by default, the client shouldn't
2671 # need the etag for writing.
2672 self.client.setHeader("ETag", None)
2673 return output['data']['data']
2600 else: 2674 else:
2601 # FIXME?? consider moving this earlier. We should 2675 self.client.response_code = 500
2602 # error out before doing any work if we can't 2676 output = _("Internal error while formatting response.\n"
2603 # display acceptable output. 2677 "accept_mime_type is not defined. This should\n"
2604 self.client.response_code = 406 2678 "never happen\n")
2605 output = ("Requested content type '%s' is not available.\n"
2606 "Acceptable types: %s" % (accept_mime_type,
2607 ", ".join(sorted(self.__accepted_content_type.keys()))))
2608 2679
2609 # Make output json end in a newline to 2680 # Make output json end in a newline to
2610 # separate from following text in logs etc.. 2681 # separate from following text in logs etc..
2611 return bs2b(output + "\n") 2682 return bs2b(output + "\n")
2612 2683

Roundup Issue Tracker: http://roundup-tracker.org/