Mercurial > p > roundup > code
changeset 5606:5fc476d4e34c
Merge REST API changes
| author | Ralf Schlatterbeck <rsc@runtux.com> |
|---|---|
| date | Wed, 30 Jan 2019 18:11:02 +0100 |
| parents | a75285092156 (current diff) 33f8bb777659 (diff) |
| children | 5df309febe49 |
| files | |
| diffstat | 13 files changed, 1799 insertions(+), 11 deletions(-) [+] |
line wrap: on
line diff
--- a/CHANGES.txt Sun Nov 11 17:09:20 2018 +0000 +++ b/CHANGES.txt Wed Jan 30 18:11:02 2019 +0100 @@ -31,6 +31,13 @@ - Support for Python 3 (3.4 and later). See doc/upgrading.txt for details of what is required to move an existing tracker from Python 2 to Python 3 (Joseph Myers, Christof Meerwald) +- Merge the Google Summer of Code Project of 2015, the implementation of + a REST-API for Roundup. This was implemented by Chau Nguyen under the + supervision of Ezio Melotti. Some additions were made, most notably we + never destroy an object in the database but retire them with the + DELETE method. We also don't allow to DELETE a whole class. Python3 + support was also fixed and we have cherry-picked two patches from the + bugs.python.org branch in the files affected by the REST-API changes. Fixed:
--- a/doc/acknowledgements.txt Sun Nov 11 17:09:20 2018 +0000 +++ b/doc/acknowledgements.txt Wed Jan 30 18:11:02 2019 +0100 @@ -204,6 +204,7 @@ Ramiro Morales, Toni Mueller, Joseph Myers, +Chau Nguyen, Stefan Niederhauser, Truls E. Næss, Bryce L Nordgren,
--- a/roundup/actions.py Sun Nov 11 17:09:20 2018 +0000 +++ b/roundup/actions.py Wed Jan 30 18:11:02 2019 +0100 @@ -2,6 +2,7 @@ # Copyright (C) 2009 Stefan Seefeld # All rights reserved. # For license terms see the file COPYING.txt. +# Actions used in REST and XMLRPC APIs # from roundup.exceptions import Unauthorised @@ -40,7 +41,19 @@ _ = gettext -class Retire(Action): +class PermCheck(Action): + def permission(self, designator): + + classname, itemid = hyperdb.splitDesignator(designator) + perm = self.db.security.hasPermission + + if not perm('Retire', self.db.getuid(), classname=classname + , itemid=itemid): + raise Unauthorised(self._('You do not have permission to retire ' + 'or restore the %(classname)s class.') + %locals()) + +class Retire(PermCheck): def handle(self, designator): @@ -57,12 +70,13 @@ self.db.commit() - def permission(self, designator): +class Restore(PermCheck): + + def handle(self, designator): classname, itemid = hyperdb.splitDesignator(designator) - if not self.db.security.hasPermission('Edit', self.db.getuid(), - classname=classname, itemid=itemid): - raise Unauthorised(self._('You do not have permission to ' - 'retire the %(classname)s class.')%classname) - + # do the restore + self.db.getclass(classname).restore(itemid) + self.db.commit() +
--- a/roundup/cgi/client.py Sun Nov 11 17:09:20 2018 +0000 +++ b/roundup/cgi/client.py Wed Jan 30 18:11:02 2019 +0100 @@ -35,6 +35,7 @@ from roundup.mailer import Mailer, MessageSendError from roundup.cgi import accept_language from roundup import xmlrpc +from roundup import rest from roundup.anypy.cookie_ import CookieError, BaseCookie, SimpleCookie, \ get_cookie_date @@ -363,6 +364,10 @@ # see if we need to re-parse the environment for the form (eg Zope) if form is None: self.form = cgi.FieldStorage(fp=request.rfile, environ=env) + # In some case (e.g. content-type application/xml), cgi + # will not parse anything. Fake a list property in this case + if self.form.list is None: + self.form.list = [] else: self.form = form @@ -421,9 +426,14 @@ def main(self): """ Wrap the real main in a try/finally so we always close off the db. """ + xmlrpc_enabled = self.instance.config.WEB_ENABLE_XMLRPC + rest_enabled = self.instance.config.WEB_ENABLE_REST try: - if self.path == 'xmlrpc': + if xmlrpc_enabled and self.path == 'xmlrpc': self.handle_xmlrpc() + elif rest_enabled and (self.path == 'rest' or + self.path[:5] == 'rest/'): + self.handle_rest() else: self.inner_main() finally: @@ -480,6 +490,24 @@ self.setHeader("Content-Length", str(len(output))) self.write(output) + def handle_rest(self): + # Set the charset and language + self.determine_charset() + self.determine_language() + # Open the database as the correct user. + # TODO: add everything to RestfulDispatcher + self.determine_user() + self.check_anonymous_access() + + # Call rest library to handle the request + handler = rest.RestfulInstance(self, self.db) + output = handler.dispatch(self.env['REQUEST_METHOD'], self.path, + self.form) + + # self.setHeader("Content-Type", "text/xml") + self.setHeader("Content-Length", str(len(output))) + self.write(output) + def add_ok_message(self, msg, escape=True): add_message(self._ok_message, msg, escape) @@ -1347,6 +1375,10 @@ klass = self.db.getclass(self.classname) except KeyError: raise NotFound('%s/%s'%(self.classname, self.nodeid)) + if int(self.nodeid) > 2**31: + # Postgres will complain with a ProgrammingError + # if we try to pass in numbers that are too large + raise NotFound('%s/%s'%(self.classname, self.nodeid)) if not klass.hasnode(self.nodeid): raise NotFound('%s/%s'%(self.classname, self.nodeid)) # with a designator, we default to item view
--- a/roundup/configuration.py Sun Nov 11 17:09:20 2018 +0000 +++ b/roundup/configuration.py Wed Jan 30 18:11:02 2019 +0100 @@ -727,6 +727,18 @@ are logged into roundup and open a roundup link from a source other than roundup (e.g. link in email)."""), + (BooleanOption, 'enable_xmlrpc', "yes", + """Whether to enable the XMLRPC API in the roundup web +interface. By default the XMLRPC endpoint is the string 'xmlrpc' +after the roundup web url configured in the 'tracker' section. +If this variable is set to 'no', the xmlrpc path has no special meaning +and will yield an error message."""), + (BooleanOption, 'enable_rest', "yes", + """Whether to enable the REST API in the roundup web +interface. By default the REST endpoint is the string 'rest' plus any +additional REST-API parameters after the roundup web url configured in +the tracker section. If this variable is set to 'no', the rest path has +no special meaning and will yield an error message."""), (CsrfSettingOption, 'csrf_enforce_token', "yes", """How do we deal with @csrf fields in posted forms. Set this to 'required' to block the post and notify
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/roundup/rest.py Wed Jan 30 18:11:02 2019 +0100 @@ -0,0 +1,1183 @@ +""" +Restful API for Roundup + +This module is free software, you may redistribute it +and/or modify under the same terms as Python. +""" + +from __future__ import print_function + +try: + from urllib.parse import urlparse +except ImportError: + from urlparse import urlparse +import os +import json +import pprint +import sys +import time +import traceback +import re + +from roundup import hyperdb +from roundup import date +from roundup import actions +from roundup.exceptions import * +from roundup.cgi.exceptions import * + +# Py3 compatible basestring +try: + basestring +except NameError: + basestring = str + unicode = str + +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 NotFound as msg: + code = 404 + data = msg + except IndexError as msg: + code = 404 + data = msg + except Unauthorised as msg: + code = 403 + data = msg + except UsageError as msg: + code = 400 + data = msg + except (AttributeError, Reject) as msg: + code = 405 + data = msg + except ValueError as 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 getattr (self.client.request, 'DEBUG_MODE', None): + 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 Routing(object): + __route_map = {} + __var_to_regex = re.compile(r"<:(\w+)>") + url_to_regex = r"([\w.\-~!$&'()*+,;=:@\%%]+)" + + @classmethod + def route(cls, rule, methods='GET'): + """A decorator that is used to register a view function for a + given URL rule: + @self.route('/') + def index(): + return 'Hello World' + + rest/ will be added to the beginning of the url string + + Args: + rule (string): the URL rule + methods (string or tuple or list): the http method + """ + # strip the '/' character from rule string + rule = rule.strip('/') + + # add 'rest/' to the rule string + if not rule.startswith('rest/'): + rule = '^rest/' + rule + '$' + + if isinstance(methods, basestring): # convert string to tuple + methods = (methods,) + methods = set(item.upper() for item in methods) + + # convert a rule to a compiled regex object + # so /data/<:class>/<:id> will become + # /data/([charset]+)/([charset]+) + # and extract the variable names to a list [(class), (id)] + func_vars = cls.__var_to_regex.findall(rule) + rule = re.compile(cls.__var_to_regex.sub(cls.url_to_regex, rule)) + + # then we decorate it: + # route_map[regex][method] = func + def decorator(func): + rule_route = cls.__route_map.get(rule, {}) + func_obj = { + 'func': func, + 'vars': func_vars + } + for method in methods: + rule_route[method] = func_obj + cls.__route_map[rule] = rule_route + return func + return decorator + + @classmethod + def execute(cls, instance, path, method, input): + # format the input + path = path.strip('/').lower() + method = method.upper() + + # find the rule match the path + # then get handler match the method + for path_regex in cls.__route_map: + match_obj = path_regex.match(path) + if match_obj: + try: + func_obj = cls.__route_map[path_regex][method] + except KeyError: + raise Reject('Method %s not allowed' % method) + + # retrieve the vars list and the function caller + list_vars = func_obj['vars'] + func = func_obj['func'] + + # zip the varlist into a dictionary, and pass it to the caller + args = dict(zip(list_vars, match_obj.groups())) + args['input'] = input + return func(instance, **args) + raise NotFound('Nothing matches the given URI') + + +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 + self.translator = client.translator + # This used to be initialized from client.instance.actions which + # would include too many actions that do not make sense in the + # REST-API context, so for now we only permit the retire and + # restore actions. + self.actions = dict (retire = actions.Retire, restore = actions.Restore) + + protocol = 'http' + host = self.client.env['HTTP_HOST'] + tracker = self.client.env['TRACKER_NAME'] + self.base_path = '%s://%s/%s/rest' % (protocol, host, tracker) + self.data_path = self.base_path + '/data' + + 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: + x = key.encode('ascii') + except UnicodeEncodeError: + raise UsageError( + 'argument %r is no valid ascii keyword' % key + ) + if value: + try: + prop = hyperdb.rawToHyperdb(self.db, cl, itemid, key, value) + except hyperdb.HyperdbValueError as 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 + + @Routing.route("/data/<:class_name>", 'GET') + @_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 class_name not in self.db.classes: + raise NotFound('Class %s not found' % class_name) + 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 = '%s/%s/' % (self.data_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 + + @Routing.route("/data/<:class_name>/<:item_id>", 'GET') + @_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 class_name not in self.db.classes: + raise NotFound('Class %s not found' % class_name) + 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 = None + for form_field in input.value: + key = form_field.name + value = form_field.value + if key == "fields": + props = value.split(",") + + if props is None: + props = list(sorted(class_obj.properties.keys())) + + try: + 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, + ) + ] + except KeyError as msg: + raise UsageError("%s field not valid" % msg) + result = { + 'id': item_id, + 'type': class_name, + 'link': '%s/%s/%s' % (self.data_path, class_name, item_id), + 'attributes': dict(result) + } + + return 200, result + + @Routing.route("/data/<:class_name>/<:item_id>/<:attr_name>", 'GET') + @_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 class_name not in self.db.classes: + raise NotFound('Class %s not found' % class_name) + 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.data_path, class_name, item_id, attr_name), + 'data': data + } + + return 200, result + + @Routing.route("/data/<:class_name>", 'POST') + @_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 class_name not in self.db.classes: + raise NotFound('Class %s not found' % class_name) + 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) as message: + raise ValueError(message) + except KeyError as msg: + raise UsageError("Must provide the %s property." % msg) + + # set the header Location + link = '%s/%s/%s' % (self.data_path, class_name, item_id) + self.client.setHeader("Location", link) + + # set the response body + result = { + 'id': item_id, + 'link': link + } + return 201, result + + @Routing.route("/data/<:class_name>/<:item_id>", 'PUT') + @_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 + """ + if class_name not in self.db.classes: + raise NotFound('Class %s not found' % class_name) + class_obj = self.db.getclass(class_name) + + props = self.props_from_args(class_obj, input.value, item_id) + for p in props: + 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) as message: + raise ValueError(message) + + result = { + 'id': item_id, + 'type': class_name, + 'link': '%s/%s/%s' % (self.data_path, class_name, item_id), + 'attribute': result + } + return 200, result + + @Routing.route("/data/<:class_name>/<:item_id>/<:attr_name>", 'PUT') + @_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 class_name not in self.db.classes: + raise NotFound('Class %s not found' % class_name) + 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) as message: + raise ValueError(message) + + result = { + 'id': item_id, + 'type': class_name, + 'link': '%s/%s/%s' % (self.data_path, class_name, item_id), + 'attribute': result + } + + return 200, result + + @Routing.route("/data/<:class_name>", 'DELETE') + @_data_decorator + def delete_collection(self, class_name, input): + """DELETE (retire) all objects in a class + There is currently no use-case, so this is disabled and + always returns Unauthorised. + + 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 + """ + raise Unauthorised('Deletion of a whole class disabled') + if class_name not in self.db.classes: + raise NotFound('Class %s not found' % class_name) + if not self.db.security.hasPermission( + 'Retire', 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( + 'Retire', self.db.getuid(), class_name, itemid=item_id + ): + raise Unauthorised( + 'Permission to retire %s %s denied' % (class_name, item_id) + ) + + count = len(class_obj.list()) + for item_id in class_obj.list(): + class_obj.retire (item_id) + + self.db.commit() + result = { + 'status': 'ok', + 'count': count + } + + return 200, result + + @Routing.route("/data/<:class_name>/<:item_id>", 'DELETE') + @_data_decorator + def delete_element(self, class_name, item_id, input): + """DELETE (retire) 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 class_name not in self.db.classes: + raise NotFound('Class %s not found' % class_name) + class_obj = self.db.classes [class_name] + if not self.db.security.hasPermission( + 'Retire', self.db.getuid(), class_name, itemid=item_id + ): + raise Unauthorised( + 'Permission to retire %s %s denied' % (class_name, item_id) + ) + + class_obj.retire (item_id) + self.db.commit() + result = { + 'status': 'ok' + } + + return 200, result + + @Routing.route("/data/<:class_name>/<:item_id>/<:attr_name>", 'DELETE') + @_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 class_name not in self.db.classes: + raise NotFound('Class %s not found' % class_name) + 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) as message: + raise ValueError(message) + + result = { + 'status': 'ok' + } + + return 200, result + + @Routing.route("/data/<:class_name>/<:item_id>", 'PATCH') + @_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 + """ + if class_name not in self.db.classes: + raise NotFound('Class %s not found' % class_name) + try: + op = input['op'].value.lower() + except KeyError: + op = self.__default_patch_op + class_obj = self.db.getclass(class_name) + + # if patch operation is action, call the action handler + action_args = [class_name + item_id] + if op == 'action': + # extract action_name and action_args from form fields + for form_field in input.value: + key = form_field.name + value = form_field.value + if key == "action_name": + name = value + elif key.startswith('action_args'): + action_args.append(value) + + if name in self.actions: + action_type = self.actions[name] + else: + raise UsageError( + 'action "%s" is not supported %s' % + (name, ','.join(self.actions.keys())) + ) + action = action_type(self.db, self.translator) + result = action.execute(*action_args) + + result = { + 'id': item_id, + 'type': class_name, + 'link': '%s/%s/%s' % (self.data_path, class_name, item_id), + 'result': result + } + else: + # else patch operation is processing data + props = self.props_from_args(class_obj, input.value, item_id) + + for prop in props: + 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) as message: + raise ValueError(message) + + result = { + 'id': item_id, + 'type': class_name, + 'link': '%s/%s/%s' % (self.data_path, class_name, item_id), + 'attribute': result + } + return 200, result + + @Routing.route("/data/<:class_name>/<:item_id>/<:attr_name>", 'PATCH') + @_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 + """ + if class_name not in self.db.classes: + raise NotFound('Class %s not found' % class_name) + 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) as message: + raise ValueError(message) + + result = { + 'id': item_id, + 'type': class_name, + 'link': '%s/%s/%s' % (self.data_path, class_name, item_id), + 'attribute': result + } + return 200, result + + @Routing.route("/data/<:class_name>", 'OPTIONS') + @_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 + """ + if class_name not in self.db.classes: + raise NotFound('Class %s not found' % class_name) + return 204, "" + + @Routing.route("/data/<:class_name>/<:item_id>", 'OPTIONS') + @_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 + """ + if class_name not in self.db.classes: + raise NotFound('Class %s not found' % class_name) + self.client.setHeader( + "Accept-Patch", + "application/x-www-form-urlencoded, multipart/form-data" + ) + return 204, "" + + @Routing.route("/data/<:class_name>/<:item_id>/<:attr_name>", 'OPTIONS') + @_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 + """ + if class_name not in self.db.classes: + raise NotFound('Class %s not found' % class_name) + self.client.setHeader( + "Accept-Patch", + "application/x-www-form-urlencoded, multipart/form-data" + ) + return 204, "" + + @Routing.route("/summary") + @_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(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" + ) + + # Call the appropriate method + try: + output = Routing.execute(self, uri, method, input) + except NotFound as msg: + output = self.error_obj(404, msg) + except Reject as msg: + output = self.error_obj(405, msg) + + # 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
--- a/roundup/scripts/roundup_server.py Sun Nov 11 17:09:20 2018 +0000 +++ b/roundup/scripts/roundup_server.py Wed Jan 30 18:11:02 2019 +0100 @@ -255,7 +255,7 @@ print('EXCEPTION AT', ts) traceback.print_exc() - do_GET = do_POST = do_HEAD = run_cgi + do_GET = do_POST = do_HEAD = do_PUT = do_DELETE = do_PATCH = run_cgi def index(self): ''' Print up an index of the available trackers
--- a/roundup/xmlrpc.py Sun Nov 11 17:09:20 2018 +0000 +++ b/roundup/xmlrpc.py Wed Jan 30 18:11:02 2019 +0100 @@ -179,7 +179,7 @@ return result - builtin_actions = {'retire': actions.Retire} + builtin_actions = dict (retire = actions.Retire, restore = actions.Restore) def action(self, name, *args): """Execute a named action.""" @@ -189,7 +189,8 @@ elif name in self.builtin_actions: action_type = self.builtin_actions[name] else: - raise Exception('action "%s" is not supported %s' % (name, ','.join(self.actions.keys()))) + raise Exception('action "%s" is not supported %s' + % (name, ','.join(self.actions.keys()))) action = action_type(self.db, self.translator) return action.execute(*args)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/test/rest_common.py Wed Jan 30 18:11:02 2019 +0100 @@ -0,0 +1,520 @@ +import unittest +import os +import shutil +import errno + +from roundup.cgi.exceptions import * +from roundup import password, hyperdb +from roundup.rest import RestfulInstance +from roundup.backends import list_backends +from roundup.cgi import client +import random + +from .db_test_base import setupTracker + +NEEDS_INSTANCE = 1 + + +class TestCase(): + + backend = None + + def setUp(self): + self.dirname = '_test_rest' + # set up and open a tracker + self.instance = setupTracker(self.dirname, self.backend) + + # open the database + self.db = self.instance.open('admin') + + # Get user id (user4 maybe). Used later to get data from db. + self.joeid = self.db.user.create( + username='joe', + password=password.Password('random'), + address='random@home.org', + realname='Joe Random', + roles='User' + ) + + self.db.commit() + self.db.close() + self.db = self.instance.open('joe') + # Allow joe to retire + p = self.db.security.addPermission(name='Retire', klass='issue') + self.db.security.addPermissionToRole('User', p) + + self.db.tx_Source = 'web' + + self.db.issue.addprop(tx_Source=hyperdb.String()) + self.db.msg.addprop(tx_Source=hyperdb.String()) + + self.db.post_init() + + thisdir = os.path.dirname(__file__) + vars = {} + with open(os.path.join(thisdir, "tx_Source_detector.py")) as f: + code = compile(f.read(), "tx_Source_detector.py", "exec") + exec(code, vars) + vars['init'](self.db) + + env = { + 'PATH_INFO': 'http://localhost/rounduptest/rest/', + 'HTTP_HOST': 'localhost', + 'TRACKER_NAME': 'rounduptest' + } + self.dummy_client = client.Client(self.instance, None, env, [], None) + self.empty_form = cgi.FieldStorage() + + self.server = RestfulInstance(self.dummy_client, self.db) + + def tearDown(self): + self.db.close() + try: + shutil.rmtree(self.dirname) + except OSError as error: + if error.errno not in (errno.ENOENT, errno.ESRCH): + raise + + def testGet(self): + """ + Retrieve all three users + obtain data for 'joe' + """ + # Retrieve all three users. + results = self.server.get_collection('user', self.empty_form) + self.assertEqual(self.dummy_client.response_code, 200) + self.assertEqual(len(results['data']), 3) + + # Obtain data for 'joe'. + results = self.server.get_element('user', self.joeid, self.empty_form) + results = results['data'] + self.assertEqual(self.dummy_client.response_code, 200) + self.assertEqual(results['attributes']['username'], 'joe') + self.assertEqual(results['attributes']['realname'], 'Joe Random') + + # Obtain data for 'joe'. + results = self.server.get_attribute( + 'user', self.joeid, 'username', self.empty_form + ) + self.assertEqual(self.dummy_client.response_code, 200) + self.assertEqual(results['data']['data'], 'joe') + + def testFilter(self): + """ + Retrieve all three users + obtain data for 'joe' + """ + # create sample data + try: + self.db.status.create(name='open') + except ValueError: + pass + try: + self.db.status.create(name='closed') + except ValueError: + pass + try: + self.db.priority.create(name='normal') + except ValueError: + pass + try: + self.db.priority.create(name='critical') + except ValueError: + pass + self.db.issue.create( + title='foo4', + status=self.db.status.lookup('closed'), + priority=self.db.priority.lookup('critical') + ) + self.db.issue.create( + title='foo1', + status=self.db.status.lookup('open'), + priority=self.db.priority.lookup('normal') + ) + issue_open_norm = self.db.issue.create( + title='foo2', + status=self.db.status.lookup('open'), + priority=self.db.priority.lookup('normal') + ) + issue_closed_norm = self.db.issue.create( + title='foo3', + status=self.db.status.lookup('closed'), + priority=self.db.priority.lookup('normal') + ) + issue_closed_crit = self.db.issue.create( + title='foo4', + status=self.db.status.lookup('closed'), + priority=self.db.priority.lookup('critical') + ) + issue_open_crit = self.db.issue.create( + title='foo5', + status=self.db.status.lookup('open'), + priority=self.db.priority.lookup('critical') + ) + base_path = self.dummy_client.env['PATH_INFO'] + 'data/issue/' + + # Retrieve all issue status=open + form = cgi.FieldStorage() + form.list = [ + cgi.MiniFieldStorage('where_status', 'open') + ] + results = self.server.get_collection('issue', form) + self.assertEqual(self.dummy_client.response_code, 200) + self.assertIn(get_obj(base_path, issue_open_norm), results['data']) + self.assertIn(get_obj(base_path, issue_open_crit), results['data']) + self.assertNotIn( + get_obj(base_path, issue_closed_norm), results['data'] + ) + + # Retrieve all issue status=closed and priority=critical + form = cgi.FieldStorage() + form.list = [ + cgi.MiniFieldStorage('where_status', 'closed'), + cgi.MiniFieldStorage('where_priority', 'critical') + ] + results = self.server.get_collection('issue', form) + self.assertEqual(self.dummy_client.response_code, 200) + self.assertIn(get_obj(base_path, issue_closed_crit), results['data']) + self.assertNotIn(get_obj(base_path, issue_open_crit), results['data']) + self.assertNotIn( + get_obj(base_path, issue_closed_norm), results['data'] + ) + + # Retrieve all issue status=closed and priority=normal,critical + form = cgi.FieldStorage() + form.list = [ + cgi.MiniFieldStorage('where_status', 'closed'), + cgi.MiniFieldStorage('where_priority', 'normal,critical') + ] + results = self.server.get_collection('issue', form) + self.assertEqual(self.dummy_client.response_code, 200) + self.assertIn(get_obj(base_path, issue_closed_crit), results['data']) + self.assertIn(get_obj(base_path, issue_closed_norm), results['data']) + self.assertNotIn(get_obj(base_path, issue_open_crit), results['data']) + self.assertNotIn(get_obj(base_path, issue_open_norm), results['data']) + + def testPagination(self): + """ + Retrieve all three users + obtain data for 'joe' + """ + # create sample data + for i in range(0, random.randint(5, 10)): + self.db.issue.create(title='foo' + str(i)) + + # Retrieving all the issues + results = self.server.get_collection('issue', self.empty_form) + self.assertEqual(self.dummy_client.response_code, 200) + total_length = len(results['data']) + + # Pagination will be 70% of the total result + page_size = total_length * 70 // 100 + page_zero_expected = page_size + page_one_expected = total_length - page_zero_expected + + # Retrieve page 0 + form = cgi.FieldStorage() + form.list = [ + cgi.MiniFieldStorage('page_size', page_size), + cgi.MiniFieldStorage('page_index', 0) + ] + results = self.server.get_collection('issue', form) + self.assertEqual(self.dummy_client.response_code, 200) + self.assertEqual(len(results['data']), page_zero_expected) + + # Retrieve page 1 + form = cgi.FieldStorage() + form.list = [ + cgi.MiniFieldStorage('page_size', page_size), + cgi.MiniFieldStorage('page_index', 1) + ] + results = self.server.get_collection('issue', form) + self.assertEqual(self.dummy_client.response_code, 200) + self.assertEqual(len(results['data']), page_one_expected) + + # Retrieve page 2 + form = cgi.FieldStorage() + form.list = [ + cgi.MiniFieldStorage('page_size', page_size), + cgi.MiniFieldStorage('page_index', 2) + ] + results = self.server.get_collection('issue', form) + self.assertEqual(self.dummy_client.response_code, 200) + self.assertEqual(len(results['data']), 0) + + def testPut(self): + """ + Change joe's 'realname' + Check if we can't change admin's detail + """ + # change Joe's realname via attribute uri + form = cgi.FieldStorage() + form.list = [ + cgi.MiniFieldStorage('data', 'Joe Doe Doe') + ] + results = self.server.put_attribute( + 'user', self.joeid, 'realname', form + ) + results = self.server.get_attribute( + 'user', self.joeid, 'realname', self.empty_form + ) + self.assertEqual(self.dummy_client.response_code, 200) + self.assertEqual(results['data']['data'], 'Joe Doe Doe') + + # Reset joe's 'realname'. + form = cgi.FieldStorage() + form.list = [ + cgi.MiniFieldStorage('realname', 'Joe Doe') + ] + results = self.server.put_element('user', self.joeid, form) + results = self.server.get_element('user', self.joeid, self.empty_form) + self.assertEqual(self.dummy_client.response_code, 200) + self.assertEqual(results['data']['attributes']['realname'], 'Joe Doe') + + # check we can't change admin's details + results = self.server.put_element('user', '1', form) + self.assertEqual(self.dummy_client.response_code, 403) + self.assertEqual(results['error']['status'], 403) + + def testPost(self): + """ + Post a new issue with title: foo + Verify the information of the created issue + """ + form = cgi.FieldStorage() + form.list = [ + cgi.MiniFieldStorage('title', 'foo') + ] + results = self.server.post_collection('issue', form) + self.assertEqual(self.dummy_client.response_code, 201) + issueid = results['data']['id'] + results = self.server.get_element('issue', issueid, self.empty_form) + self.assertEqual(self.dummy_client.response_code, 200) + self.assertEqual(results['data']['attributes']['title'], 'foo') + self.assertEqual(self.db.issue.get(issueid, "tx_Source"), 'web') + + def testPostFile(self): + """ + Post a new file with content: hello\r\nthere + Verify the information of the created file + """ + form = cgi.FieldStorage() + form.list = [ + cgi.MiniFieldStorage('content', 'hello\r\nthere') + ] + results = self.server.post_collection('file', form) + self.assertEqual(self.dummy_client.response_code, 201) + fileid = results['data']['id'] + results = self.server.get_element('file', fileid, self.empty_form) + results = results['data'] + self.assertEqual(self.dummy_client.response_code, 200) + self.assertEqual(results['attributes']['content'], 'hello\r\nthere') + + def testAuthDeniedPut(self): + """ + Test unauthorized PUT request + """ + # Wrong permissions (caught by roundup security module). + form = cgi.FieldStorage() + form.list = [ + cgi.MiniFieldStorage('realname', 'someone') + ] + results = self.server.put_element('user', '1', form) + self.assertEqual(self.dummy_client.response_code, 403) + self.assertEqual(results['error']['status'], 403) + + def testAuthDeniedPost(self): + """ + Test unauthorized POST request + """ + form = cgi.FieldStorage() + form.list = [ + cgi.MiniFieldStorage('username', 'blah') + ] + results = self.server.post_collection('user', form) + self.assertEqual(self.dummy_client.response_code, 403) + self.assertEqual(results['error']['status'], 403) + + def testAuthAllowedPut(self): + """ + Test authorized PUT request + """ + self.db.setCurrentUser('admin') + form = cgi.FieldStorage() + form.list = [ + cgi.MiniFieldStorage('realname', 'someone') + ] + try: + self.server.put_element('user', '2', form) + except Unauthorised as err: + self.fail('raised %s' % err) + finally: + self.db.setCurrentUser('joe') + + def testAuthAllowedPost(self): + """ + Test authorized POST request + """ + self.db.setCurrentUser('admin') + form = cgi.FieldStorage() + form.list = [ + cgi.MiniFieldStorage('username', 'blah') + ] + try: + self.server.post_collection('user', form) + except Unauthorised as err: + self.fail('raised %s' % err) + finally: + self.db.setCurrentUser('joe') + + def testDeleteAttributeUri(self): + """ + Test Delete an attribute + """ + # create a new issue with userid 1 in the nosy list + issue_id = self.db.issue.create(title='foo', nosy=['1']) + + # remove the title and nosy + results = self.server.delete_attribute( + 'issue', issue_id, 'title', self.empty_form + ) + self.assertEqual(self.dummy_client.response_code, 200) + + results = self.server.delete_attribute( + 'issue', issue_id, 'nosy', self.empty_form + ) + self.assertEqual(self.dummy_client.response_code, 200) + + # verify the result + results = self.server.get_element('issue', issue_id, self.empty_form) + results = results['data'] + self.assertEqual(self.dummy_client.response_code, 200) + self.assertEqual(len(results['attributes']['nosy']), 0) + self.assertListEqual(results['attributes']['nosy'], []) + self.assertEqual(results['attributes']['title'], None) + + def testPatchAdd(self): + """ + Test Patch op 'Add' + """ + # create a new issue with userid 1 in the nosy list + issue_id = self.db.issue.create(title='foo', nosy=['1']) + + # add userid 2 to the nosy list + form = cgi.FieldStorage() + form.list = [ + cgi.MiniFieldStorage('op', 'add'), + cgi.MiniFieldStorage('nosy', '2') + ] + results = self.server.patch_element('issue', issue_id, form) + self.assertEqual(self.dummy_client.response_code, 200) + + # verify the result + results = self.server.get_element('issue', issue_id, self.empty_form) + results = results['data'] + self.assertEqual(self.dummy_client.response_code, 200) + self.assertEqual(len(results['attributes']['nosy']), 2) + self.assertListEqual(results['attributes']['nosy'], ['1', '2']) + + def testPatchReplace(self): + """ + Test Patch op 'Replace' + """ + # create a new issue with userid 1 in the nosy list and status = 1 + issue_id = self.db.issue.create(title='foo', nosy=['1'], status='1') + + # replace userid 2 to the nosy list and status = 3 + form = cgi.FieldStorage() + form.list = [ + cgi.MiniFieldStorage('op', 'replace'), + cgi.MiniFieldStorage('nosy', '2'), + cgi.MiniFieldStorage('status', '3') + ] + results = self.server.patch_element('issue', issue_id, form) + self.assertEqual(self.dummy_client.response_code, 200) + + # verify the result + results = self.server.get_element('issue', issue_id, self.empty_form) + results = results['data'] + self.assertEqual(self.dummy_client.response_code, 200) + self.assertEqual(results['attributes']['status'], '3') + self.assertEqual(len(results['attributes']['nosy']), 1) + self.assertListEqual(results['attributes']['nosy'], ['2']) + + def testPatchRemoveAll(self): + """ + Test Patch Action 'Remove' + """ + # create a new issue with userid 1 and 2 in the nosy list + issue_id = self.db.issue.create(title='foo', nosy=['1', '2']) + + # remove the nosy list and the title + form = cgi.FieldStorage() + form.list = [ + cgi.MiniFieldStorage('op', 'remove'), + cgi.MiniFieldStorage('nosy', ''), + cgi.MiniFieldStorage('title', '') + ] + results = self.server.patch_element('issue', issue_id, form) + self.assertEqual(self.dummy_client.response_code, 200) + + # verify the result + results = self.server.get_element('issue', issue_id, self.empty_form) + results = results['data'] + self.assertEqual(self.dummy_client.response_code, 200) + self.assertEqual(results['attributes']['title'], None) + self.assertEqual(len(results['attributes']['nosy']), 0) + self.assertEqual(results['attributes']['nosy'], []) + + def testPatchAction(self): + """ + Test Patch Action 'Action' + """ + # create a new issue with userid 1 and 2 in the nosy list + issue_id = self.db.issue.create(title='foo') + + # execute action retire + form = cgi.FieldStorage() + form.list = [ + cgi.MiniFieldStorage('op', 'action'), + cgi.MiniFieldStorage('action_name', 'retire') + ] + results = self.server.patch_element('issue', issue_id, form) + self.assertEqual(self.dummy_client.response_code, 200) + + # verify the result + self.assertTrue(self.db.issue.is_retired(issue_id)) + + def testPatchRemove(self): + """ + Test Patch Action 'Remove' only some element from a list + """ + # create a new issue with userid 1, 2, 3 in the nosy list + issue_id = self.db.issue.create(title='foo', nosy=['1', '2', '3']) + + # remove the nosy list and the title + form = cgi.FieldStorage() + form.list = [ + cgi.MiniFieldStorage('op', 'remove'), + cgi.MiniFieldStorage('nosy', '1, 2'), + ] + results = self.server.patch_element('issue', issue_id, form) + self.assertEqual(self.dummy_client.response_code, 200) + + # verify the result + results = self.server.get_element('issue', issue_id, self.empty_form) + results = results['data'] + self.assertEqual(self.dummy_client.response_code, 200) + self.assertEqual(len(results['attributes']['nosy']), 1) + self.assertEqual(results['attributes']['nosy'], ['3']) + + +def get_obj(path, id): + return { + 'id': id, + 'link': path + id + } + +if __name__ == '__main__': + runner = unittest.TextTestRunner() + unittest.main(testRunner=runner)
--- a/test/test_anydbm.py Sun Nov 11 17:09:20 2018 +0000 +++ b/test/test_anydbm.py Wed Jan 30 18:11:02 2019 +0100 @@ -20,6 +20,7 @@ from .db_test_base import DBTest, ROTest, SchemaTest, ClassicInitTest, config from .db_test_base import HTMLItemTest, SpecialActionTest +from .rest_common import TestCase as RestTestCase class anydbmOpener: module = get_backend('anydbm') @@ -56,4 +57,7 @@ unittest.TestCase): backend = 'anydbm' +class anydbmRestTest (RestTestCase, unittest.TestCase): + backend = 'anydbm' + # vim: set filetype=python ts=4 sw=4 et si
--- a/test/test_mysql.py Sun Nov 11 17:09:20 2018 +0000 +++ b/test/test_mysql.py Wed Jan 30 18:11:02 2019 +0100 @@ -24,6 +24,7 @@ from .db_test_base import DBTest, ROTest, config, SchemaTest, ClassicInitTest from .db_test_base import ConcurrentDBTest, HTMLItemTest, FilterCacheTest from .db_test_base import SpecialActionTest +from .rest_common import TestCase as RestTestCase class mysqlOpener: @@ -145,4 +146,8 @@ SpecialActionTest.tearDown(self) mysqlOpener.tearDown(self) +@skip_mysql +class mysqlRestTest (RestTestCase, unittest.TestCase): + backend = 'mysql' + # vim: set et sts=4 sw=4 :
--- a/test/test_postgresql.py Sun Nov 11 17:09:20 2018 +0000 +++ b/test/test_postgresql.py Wed Jan 30 18:11:02 2019 +0100 @@ -24,6 +24,7 @@ from .db_test_base import DBTest, ROTest, config, SchemaTest, ClassicInitTest from .db_test_base import ConcurrentDBTest, HTMLItemTest, FilterCacheTest from .db_test_base import ClassicInitBase, setupTracker, SpecialActionTest +from .rest_common import TestCase as RestTestCase if not have_backend('postgresql'): # FIX: workaround for a bug in pytest.mark.skip(): @@ -225,4 +226,8 @@ SpecialActionTest.tearDown(self) postgresqlOpener.tearDown(self) +@skip_postgresql +class postgresqlRestTest (RestTestCase, unittest.TestCase): + backend = 'postgresql' + # vim: set et sts=4 sw=4 :
--- a/test/test_sqlite.py Sun Nov 11 17:09:20 2018 +0000 +++ b/test/test_sqlite.py Wed Jan 30 18:11:02 2019 +0100 @@ -21,6 +21,7 @@ from .db_test_base import DBTest, ROTest, SchemaTest, ClassicInitTest, config from .db_test_base import ConcurrentDBTest, FilterCacheTest from .db_test_base import SpecialActionTest +from .rest_common import TestCase as RestTestCase class sqliteOpener: if have_backend('sqlite'): @@ -61,3 +62,6 @@ from .session_common import SessionTest class sqliteSessionTest(sqliteOpener, SessionTest, unittest.TestCase): pass + +class sqliteRestTest (RestTestCase, unittest.TestCase): + backend = 'sqlite'
