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'

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