Mercurial > p > roundup > code
view roundup/rest.py @ 5596:55fa81de6a57 REST-rebased
Added summary page
Change data uri of class methods from /rest/class to /rest/data/class
committer: Ralf Schlatterbeck <rsc@runtux.com>
| author | Chau Nguyen <dangchau1991@yahoo.com> |
|---|---|
| date | Wed, 30 Jan 2019 10:26:35 +0100 |
| parents | 65caddd54da2 |
| children | de9933cfcfc4 |
line wrap: on
line source
""" Restful API for Roundup This module is free software, you may redistribute it and/or modify under the same terms as Python. """ import urlparse import os import json import pprint import sys import time import traceback import xml from roundup import hyperdb from roundup import date from roundup.exceptions import * def _data_decorator(func): """Wrap the returned data into an object.""" def format_object(self, *args, **kwargs): # get the data / error from function try: code, data = func(self, *args, **kwargs) except IndexError, msg: code = 404 data = msg except Unauthorised, msg: code = 403 data = msg except UsageError, msg: code = 400 data = msg except (AttributeError, Reject), msg: code = 405 data = msg except ValueError, msg: code = 409 data = msg except NotImplementedError: code = 402 # nothing to pay, just a mark for debugging purpose data = 'Method under development' except: exc, val, tb = sys.exc_info() code = 400 ts = time.ctime() if self.client.request.DEBUG_MODE: data = val else: data = '%s: An error occurred. Please check the server log' \ ' for more information.' % ts # out to the logfile print 'EXCEPTION AT', ts traceback.print_exc() # decorate it self.client.response_code = code if code >= 400: # any error require error format result = { 'error': { 'status': code, 'msg': data } } else: result = { 'data': data } return result return format_object def parse_accept_header(accept): """ Parse the Accept header *accept*, returning a list with 3-tuples of [(str(media_type), dict(params), float(q_value)),] ordered by q values. If the accept header includes vendor-specific types like:: application/vnd.yourcompany.yourproduct-v1.1+json It will actually convert the vendor and version into parameters and convert the content type into `application/json` so appropriate content negotiation decisions can be made. Default `q` for values that are not specified is 1.0 # Based on https://gist.github.com/samuraisam/2714195 # Also, based on a snipped found in this project: # https://github.com/martinblech/mimerender """ result = [] for media_range in accept.split(","): parts = media_range.split(";") media_type = parts.pop(0).strip() media_params = [] # convert vendor-specific content types into something useful (see # docstring) typ, subtyp = media_type.split('/') # check for a + in the sub-type if '+' in subtyp: # if it exists, determine if the subtype is a vendor-specific type vnd, sep, extra = subtyp.partition('+') if vnd.startswith('vnd'): # and then... if it ends in something like "-v1.1" parse the # version out if '-v' in vnd: vnd, sep, rest = vnd.rpartition('-v') if len(rest): # add the version as a media param try: version = media_params.append(('version', float(rest))) except ValueError: version = 1.0 # could not be parsed # add the vendor code as a media param media_params.append(('vendor', vnd)) # and re-write media_type to something like application/json so # it can be used usefully when looking up emitters media_type = '{}/{}'.format(typ, extra) q = 1.0 for part in parts: (key, value) = part.lstrip().split("=", 1) key = key.strip() value = value.strip() if key == "q": q = float(value) else: media_params.append((key, value)) result.append((media_type, dict(media_params), q)) result.sort(lambda x, y: -cmp(x[2], y[2])) return result class RestfulInstance(object): """The RestfulInstance performs REST request from the client""" __default_patch_op = "replace" # default operator for PATCH method __accepted_content_type = { "application/json": "json", "*/*": "json" # "application/xml": "xml" } __default_accept_type = "json" def __init__(self, client, db): self.client = client self.db = db protocol = 'http' host = self.client.env['HTTP_HOST'] tracker = self.client.env['TRACKER_NAME'] self.base_path = '%s://%s/%s/rest/' % (protocol, host, tracker) def props_from_args(self, cl, args, itemid=None): """Construct a list of properties from the given arguments, and return them after validation. Args: cl (string): class object of the resource args (list): the submitted form of the user itemid (string, optional): itemid of the object Returns: dict: dictionary of validated properties """ class_props = cl.properties.keys() props = {} # props = dict.fromkeys(class_props, None) for arg in args: key = arg.name value = arg.value if key not in class_props: continue props[key] = self.prop_from_arg(cl, key, value, itemid) return props def prop_from_arg(self, cl, key, value, itemid=None): """Construct a property from the given argument, and return them after validation. Args: cl (string): class object of the resource key (string): attribute key value (string): attribute value itemid (string, optional): itemid of the object Returns: value: value of validated properties """ prop = None if isinstance(key, unicode): try: key = key.encode('ascii') except UnicodeEncodeError: raise UsageError( 'argument %r is no valid ascii keyword' % key ) if isinstance(value, unicode): value = value.encode('utf-8') if value: try: prop = hyperdb.rawToHyperdb( self.db, cl, itemid, key, value ) except hyperdb.HyperdbValueError, msg: raise UsageError(msg) return prop def error_obj(self, status, msg, source=None): """Return an error object""" self.client.response_code = status result = { 'error': { 'status': status, 'msg': msg } } if source is not None: result['error']['source'] = source return result def patch_data(self, op, old_val, new_val): """Perform patch operation based on old_val and new_val Args: op (string): PATCH operation: add, replace, remove old_val: old value of the property new_val: new value of the property Returns: result (string): value after performed the operation """ # add operation: If neither of the value is None, use the other one # Otherwise, concat those 2 value if op == 'add': if old_val is None: result = new_val elif new_val is None: result = old_val else: result = old_val + new_val # Replace operation: new value is returned elif op == 'replace': result = new_val # Remove operation: # if old_val is not a list/dict, change it to None # if old_val is a list/dict, but the parameter is empty, # change it to none # if old_val is a list/dict, and parameter is not empty # proceed to remove the values from parameter from the list/dict elif op == 'remove': if isinstance(old_val, list): if new_val is None: result = [] elif isinstance(new_val, list): result = [x for x in old_val if x not in new_val] else: if new_val in old_val: old_val.remove(new_val) elif isinstance(old_val, dict): if new_val is None: result = {} elif isinstance(new_val, dict): for x in new_val: old_val.pop(x, None) else: old_val.pop(new_val, None) else: result = None else: raise UsageError('PATCH Operation %s is not allowed' % op) return result @_data_decorator def get_collection(self, class_name, input): """GET resource from class URI. This function returns only items have View permission class_name should be valid already Args: class_name (string): class name of the resource (Ex: issue, msg) input (list): the submitted form of the user Returns: int: http status code 200 (OK) list: list of reference item in the class id: id of the object link: path to the object """ if not self.db.security.hasPermission( 'View', self.db.getuid(), class_name ): raise Unauthorised('Permission to view %s denied' % class_name) class_obj = self.db.getclass(class_name) class_path = self.base_path + class_name # Handle filtering and pagination filter_props = {} page = { 'size': None, 'index': None } for form_field in input.value: key = form_field.name value = form_field.value if key.startswith("where_"): # serve the filter purpose key = key[6:] filter_props[key] = [ getattr(self.db, key).lookup(p) for p in value.split(",") ] elif key.startswith("page_"): # serve the paging purpose key = key[5:] value = int(value) page[key] = value if not filter_props: obj_list = class_obj.list() else: obj_list = class_obj.filter(None, filter_props) # extract result from data result = [ {'id': item_id, 'link': class_path + item_id} for item_id in obj_list if self.db.security.hasPermission( 'View', self.db.getuid(), class_name, itemid=item_id ) ] # pagination if page['size'] is not None and page['index'] is not None: page_start = max(page['index'] * page['size'], 0) page_end = min(page_start + page['size'], len(result)) result = result[page_start:page_end] self.client.setHeader("X-Count-Total", str(len(result))) return 200, result @_data_decorator def get_element(self, class_name, item_id, input): """GET resource from object URI. This function returns only properties have View permission class_name and item_id should be valid already Args: class_name (string): class name of the resource (Ex: issue, msg) item_id (string): id of the resource (Ex: 12, 15) input (list): the submitted form of the user Returns: int: http status code 200 (OK) dict: a dictionary represents the object id: id of the object type: class name of the object link: link to the object attributes: a dictionary represent the attributes of the object """ if not self.db.security.hasPermission( 'View', self.db.getuid(), class_name, itemid=item_id ): raise Unauthorised( 'Permission to view %s%s denied' % (class_name, item_id) ) class_obj = self.db.getclass(class_name) props = class_obj.properties.keys() props.sort() # sort properties result = [ (prop_name, class_obj.get(item_id, prop_name)) for prop_name in props if self.db.security.hasPermission( 'View', self.db.getuid(), class_name, prop_name, ) ] result = { 'id': item_id, 'type': class_name, 'link': self.base_path + class_name + item_id, 'attributes': dict(result) } return 200, result @_data_decorator def get_attribute(self, class_name, item_id, attr_name, input): """GET resource from attribute URI. This function returns only attribute has View permission class_name should be valid already Args: class_name (string): class name of the resource (Ex: issue, msg) item_id (string): id of the resource (Ex: 12, 15) attr_name (string): attribute of the resource (Ex: title, nosy) input (list): the submitted form of the user Returns: int: http status code 200 (OK) list: a dictionary represents the attribute id: id of the object type: class name of the attribute link: link to the attribute data: data of the requested attribute """ if not self.db.security.hasPermission( 'View', self.db.getuid(), class_name, attr_name, item_id ): raise Unauthorised( 'Permission to view %s%s %s denied' % (class_name, item_id, attr_name) ) class_obj = self.db.getclass(class_name) data = class_obj.get(item_id, attr_name) result = { 'id': item_id, 'type': type(data), 'link': "%s%s%s/%s" % (self.base_path, class_name, item_id, attr_name), 'data': data } return 200, result @_data_decorator def post_collection(self, class_name, input): """POST a new object to a class If the item is successfully created, the "Location" header will also contain the link to the created object Args: class_name (string): class name of the resource (Ex: issue, msg) input (list): the submitted form of the user Returns: int: http status code 201 (Created) dict: a reference item to the created object id: id of the object link: path to the object """ if not self.db.security.hasPermission( 'Create', self.db.getuid(), class_name ): raise Unauthorised('Permission to create %s denied' % class_name) class_obj = self.db.getclass(class_name) # convert types props = self.props_from_args(class_obj, input.value) # check for the key property key = class_obj.getkey() if key and key not in props: raise UsageError("Must provide the '%s' property." % key) for key in props: if not self.db.security.hasPermission( 'Create', self.db.getuid(), class_name, property=key ): raise Unauthorised( 'Permission to create %s.%s denied' % (class_name, key) ) # do the actual create try: item_id = class_obj.create(**props) self.db.commit() except (TypeError, IndexError, ValueError), message: raise ValueError(message) except KeyError, msg: raise UsageError("Must provide the %s property." % msg) # set the header Location link = self.base_path + class_name + item_id self.client.setHeader("Location", link) # set the response body result = { 'id': item_id, 'link': link } return 201, result @_data_decorator def post_element(self, class_name, item_id, input): """POST to an object of a class is not allowed""" raise Reject('POST to an item is not allowed') @_data_decorator def post_attribute(self, class_name, item_id, attr_name, input): """POST to an attribute of an object is not allowed""" raise Reject('POST to an attribute is not allowed') @_data_decorator def put_collection(self, class_name, input): """PUT a class is not allowed""" raise Reject('PUT a class is not allowed') @_data_decorator def put_element(self, class_name, item_id, input): """PUT a new content to an object Replace the content of the existing object Args: class_name (string): class name of the resource (Ex: issue, msg) item_id (string): id of the resource (Ex: 12, 15) input (list): the submitted form of the user Returns: int: http status code 200 (OK) dict: a dictionary represents the modified object id: id of the object type: class name of the object link: link to the object attributes: a dictionary represent only changed attributes of the object """ class_obj = self.db.getclass(class_name) props = self.props_from_args(class_obj, input.value, item_id) for p in props.iterkeys(): if not self.db.security.hasPermission( 'Edit', self.db.getuid(), class_name, p, item_id ): raise Unauthorised( 'Permission to edit %s of %s%s denied' % (p, class_name, item_id) ) try: result = class_obj.set(item_id, **props) self.db.commit() except (TypeError, IndexError, ValueError), message: raise ValueError(message) result = { 'id': item_id, 'type': class_name, 'link': self.base_path + class_name + item_id, 'attribute': result } return 200, result @_data_decorator def put_attribute(self, class_name, item_id, attr_name, input): """PUT an attribute to an object Args: class_name (string): class name of the resource (Ex: issue, msg) item_id (string): id of the resource (Ex: 12, 15) attr_name (string): attribute of the resource (Ex: title, nosy) input (list): the submitted form of the user Returns: int: http status code 200 (OK) dict:a dictionary represents the modified object id: id of the object type: class name of the object link: link to the object attributes: a dictionary represent only changed attributes of the object """ if not self.db.security.hasPermission( 'Edit', self.db.getuid(), class_name, attr_name, item_id ): raise Unauthorised( 'Permission to edit %s%s %s denied' % (class_name, item_id, attr_name) ) class_obj = self.db.getclass(class_name) props = { attr_name: self.prop_from_arg( class_obj, attr_name, input['data'].value, item_id ) } try: result = class_obj.set(item_id, **props) self.db.commit() except (TypeError, IndexError, ValueError), message: raise ValueError(message) result = { 'id': item_id, 'type': class_name, 'link': self.base_path + class_name + item_id, 'attribute': result } return 200, result @_data_decorator def delete_collection(self, class_name, input): """DELETE all objects in a class Args: class_name (string): class name of the resource (Ex: issue, msg) input (list): the submitted form of the user Returns: int: http status code 200 (OK) dict: status (string): 'ok' count (int): number of deleted objects """ if not self.db.security.hasPermission( 'Delete', self.db.getuid(), class_name ): raise Unauthorised('Permission to delete %s denied' % class_name) class_obj = self.db.getclass(class_name) for item_id in class_obj.list(): if not self.db.security.hasPermission( 'Delete', self.db.getuid(), class_name, itemid=item_id ): raise Unauthorised( 'Permission to delete %s %s denied' % (class_name, item_id) ) count = len(class_obj.list()) for item_id in class_obj.list(): self.db.destroynode(class_name, item_id) self.db.commit() result = { 'status': 'ok', 'count': count } return 200, result @_data_decorator def delete_element(self, class_name, item_id, input): """DELETE an object in a class Args: class_name (string): class name of the resource (Ex: issue, msg) item_id (string): id of the resource (Ex: 12, 15) input (list): the submitted form of the user Returns: int: http status code 200 (OK) dict: status (string): 'ok' """ if not self.db.security.hasPermission( 'Delete', self.db.getuid(), class_name, itemid=item_id ): raise Unauthorised( 'Permission to delete %s %s denied' % (class_name, item_id) ) self.db.destroynode(class_name, item_id) self.db.commit() result = { 'status': 'ok' } return 200, result @_data_decorator def delete_attribute(self, class_name, item_id, attr_name, input): """DELETE an attribute in a object by setting it to None or empty Args: class_name (string): class name of the resource (Ex: issue, msg) item_id (string): id of the resource (Ex: 12, 15) attr_name (string): attribute of the resource (Ex: title, nosy) input (list): the submitted form of the user Returns: int: http status code 200 (OK) dict: status (string): 'ok' """ if not self.db.security.hasPermission( 'Edit', self.db.getuid(), class_name, attr_name, item_id ): raise Unauthorised( 'Permission to delete %s%s %s denied' % (class_name, item_id, attr_name) ) class_obj = self.db.getclass(class_name) props = {} prop_obj = class_obj.get(item_id, attr_name) if isinstance(prop_obj, list): props[attr_name] = [] else: props[attr_name] = None try: class_obj.set(item_id, **props) self.db.commit() except (TypeError, IndexError, ValueError), message: raise ValueError(message) result = { 'status': 'ok' } return 200, result @_data_decorator def patch_collection(self, class_name, input): """PATCH a class is not allowed""" raise Reject('PATCH a class is not allowed') @_data_decorator def patch_element(self, class_name, item_id, input): """PATCH an object Patch an element using 3 operators ADD : Append new value to the object's attribute REPLACE: Replace object's attribute REMOVE: Clear object's attribute Args: class_name (string): class name of the resource (Ex: issue, msg) item_id (string): id of the resource (Ex: 12, 15) input (list): the submitted form of the user Returns: int: http status code 200 (OK) dict: a dictionary represents the modified object id: id of the object type: class name of the object link: link to the object attributes: a dictionary represent only changed attributes of the object """ try: op = input['op'].value.lower() except KeyError: op = self.__default_patch_op class_obj = self.db.getclass(class_name) props = self.props_from_args(class_obj, input.value, item_id) for prop, value in props.iteritems(): if not self.db.security.hasPermission( 'Edit', self.db.getuid(), class_name, prop, item_id ): raise Unauthorised( 'Permission to edit %s of %s%s denied' % (prop, class_name, item_id) ) props[prop] = self.patch_data( op, class_obj.get(item_id, prop), props[prop] ) try: result = class_obj.set(item_id, **props) self.db.commit() except (TypeError, IndexError, ValueError), message: raise ValueError(message) result = { 'id': item_id, 'type': class_name, 'link': self.base_path + class_name + item_id, 'attribute': result } return 200, result @_data_decorator def patch_attribute(self, class_name, item_id, attr_name, input): """PATCH an attribute of an object Patch an element using 3 operators ADD : Append new value to the attribute REPLACE: Replace attribute REMOVE: Clear attribute Args: class_name (string): class name of the resource (Ex: issue, msg) item_id (string): id of the resource (Ex: 12, 15) attr_name (string): attribute of the resource (Ex: title, nosy) input (list): the submitted form of the user Returns: int: http status code 200 (OK) dict: a dictionary represents the modified object id: id of the object type: class name of the object link: link to the object attributes: a dictionary represent only changed attributes of the object """ try: op = input['op'].value.lower() except KeyError: op = self.__default_patch_op if not self.db.security.hasPermission( 'Edit', self.db.getuid(), class_name, attr_name, item_id ): raise Unauthorised( 'Permission to edit %s%s %s denied' % (class_name, item_id, attr_name) ) prop = attr_name class_obj = self.db.getclass(class_name) props = { prop: self.prop_from_arg( class_obj, prop, input['data'].value, item_id ) } props[prop] = self.patch_data( op, class_obj.get(item_id, prop), props[prop] ) try: result = class_obj.set(item_id, **props) self.db.commit() except (TypeError, IndexError, ValueError), message: raise ValueError(message) result = { 'id': item_id, 'type': class_name, 'link': self.base_path + class_name + item_id, 'attribute': result } return 200, result @_data_decorator def options_collection(self, class_name, input): """OPTION return the HTTP Header for the class uri Returns: int: http status code 204 (No content) body (string): an empty string """ return 204, "" @_data_decorator def options_element(self, class_name, item_id, input): """OPTION return the HTTP Header for the object uri Returns: int: http status code 204 (No content) body (string): an empty string """ self.client.setHeader( "Accept-Patch", "application/x-www-form-urlencoded, multipart/form-data" ) return 204, "" @_data_decorator def option_attribute(self, class_name, item_id, attr_name, input): """OPTION return the HTTP Header for the attribute uri Returns: int: http status code 204 (No content) body (string): an empty string """ self.client.setHeader( "Accept-Patch", "application/x-www-form-urlencoded, multipart/form-data" ) return 204, "" @_data_decorator def summary(self, input): """Get a summary of resource from class URI. This function returns only items have View permission class_name should be valid already Args: class_name (string): class name of the resource (Ex: issue, msg) input (list): the submitted form of the user Returns: int: http status code 200 (OK) list: """ if not self.db.security.hasPermission( 'View', self.db.getuid(), 'issue' ) and not self.db.security.hasPermission( 'View', self.db.getuid(), 'status' ) and not self.db.security.hasPermission( 'View', self.db.getuid(), 'issue' ): raise Unauthorised('Permission to view summary denied') old = date.Date('-1w') created = [] summary = {} messages = [] # loop through all the recently-active issues for issue_id in self.db.issue.filter(None, {'activity': '-1w;'}): num = 0 status_name = self.db.status.get( self.db.issue.get(issue_id, 'status'), 'name' ) issue_object = { 'id': issue_id, 'link': self.base_path + 'issue' + issue_id, 'title': self.db.issue.get(issue_id, 'title') } for x, ts, uid, action, data in self.db.issue.history(issue_id): if ts < old: continue if action == 'create': created.append(issue_object) elif action == 'set' and 'messages' in data: num += 1 summary.setdefault(status_name, []).append(issue_object) messages.append((num, issue_object)) messages.sort(reverse=True) result = { 'created': created, 'summary': summary, 'most_discussed': messages[:10] } return 200, result def dispatch(self, method, uri, input): """format and process the request""" # if X-HTTP-Method-Override is set, follow the override method headers = self.client.request.headers method = headers.getheader('X-HTTP-Method-Override') or method # parse Accept header and get the content type accept_header = parse_accept_header(headers.getheader('Accept')) accept_type = "invalid" for part in accept_header: if part[0] in self.__accepted_content_type: accept_type = self.__accepted_content_type[part[0]] # get the request format for response # priority : extension from uri (/rest/issue.json), # header (Accept: application/json, application/xml) # default (application/json) ext_type = os.path.splitext(urlparse.urlparse(uri).path)[1][1:] data_type = ext_type or accept_type or self.__default_accept_type # check for pretty print try: pretty_output = input['pretty'].value.lower() == "true" except KeyError: pretty_output = False # add access-control-allow-* to support CORS self.client.setHeader("Access-Control-Allow-Origin", "*") self.client.setHeader( "Access-Control-Allow-Headers", "Content-Type, Authorization, X-HTTP-Method-Override" ) self.client.setHeader( "Allow", "HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH" ) self.client.setHeader( "Access-Control-Allow-Methods", "HEAD, OPTIONS, GET, PUT, DELETE, PATCH" ) # PATH is split to multiple pieces # 0 - rest # 1 - data # 2 - resource # 3 - attribute uri_split = uri.lower().split("/") # Call the appropriate method if len(uri_split) == 2 and uri_split[1] == 'summary': output = self.summary(input) elif 4 >= len(uri_split) > 2 and uri_split[1] == 'data': resource_uri = uri_split[2] try: class_name, item_id = hyperdb.splitDesignator(resource_uri) except hyperdb.DesignatorError: class_name = resource_uri item_id = None if class_name not in self.db.classes: output = self.error_obj(404, "Not found") elif item_id is None: if len(uri_split) == 3: output = getattr( self, "%s_collection" % method.lower() )(class_name, input) else: output = self.error_obj(404, "Not found") else: if len(uri_split) == 3: output = getattr( self, "%s_element" % method.lower() )(class_name, item_id, input) else: output = getattr( self, "%s_attribute" % method.lower() )(class_name, item_id, uri_split[3], input) else: output = self.error_obj(404, "Not found") # Format the content type if data_type.lower() == "json": self.client.setHeader("Content-Type", "application/json") if pretty_output: indent = 4 else: indent = None output = RoundupJSONEncoder(indent=indent).encode(output) else: self.client.response_code = 406 output = "Content type is not accepted by client" return output class RoundupJSONEncoder(json.JSONEncoder): """RoundupJSONEncoder overrides the default JSONEncoder to handle all types of the object without returning any error""" def default(self, obj): try: result = json.JSONEncoder.default(self, obj) except TypeError: result = str(obj) return result
