view roundup/scripts/roundup_xmlrpc_server.py @ 5710:0b79bfcb3312

Add support for making an idempotent POST. This allows retrying a POST that was interrupted. It involves creating a post once only (poe) url /rest/data/<class>/@poe/<random_token>. This url acts the same as a post to /rest/data/<class>. However once the @poe url is used, it can't be used for a second POST. To make these changes: 1) Take the body of post_collection into a new post_collection_inner function. Have post_collection call post_collection_inner. 2) Add a handler for POST to rest/data/class/@poe. This will return a unique POE url. By default the url expires after 30 minutes. The POE random token is only good for a specific user and is stored in the session db. 3) Add a handler for POST to rest/data/<class>/@poe/<random token>. The random token generated in 2 is validated for proper class (if token is not generic) and proper user and must not have expired. If everything is valid, call post_collection_inner to process the input and generate the new entry. To make recognition of 2 stable (so it's not confused with rest/data/<:class_name>/<:item_id>), removed @ from Routing::url_to_regex. The current Routing.execute method stops on the first regular expression to match the URL. Since item_id doesn't accept a POST, I was getting 405 bad method sometimes. My guess is the order of the regular expressions is not stable, so sometime I would get the right regexp for /data/<class>/@poe and sometime I would get the one for /data/<class>/<item_id>. By removing the @ from the url_to_regexp, there was no way for the item_id case to match @poe. There are alternate fixes we may need to look at. If a regexp matches but the method does not, return to the regexp matching loop in execute() looking for another match. Only once every possible match has failed should the code return a 405 method failure. Another fix is to implement a more sophisticated mechanism so that @Routing.route("/data/<:class_name>/<:item_id>/<:attr_name>", 'PATCH') has different regexps for matching <:class_name> <:item_id> and <:attr_name>. Currently the regexp specified by url_to_regex is used for every component. Other fixes: Made failure to find any props in props_from_args return an empty dict rather than throwing an unhandled error. Make __init__ for SimulateFieldStorageFromJson handle an empty json doc. Useful for POSTing to rest/data/class/@poe with an empty document. Testing: added testPostPOE to test/rest_common.py that I think covers all the code that was added. Documentation: Add doc to rest.txt in the "Client API" section titled: Safely Re-sending POST". Move existing section "Adding new rest endpoints" in "Client API" to a new second level section called "Programming the REST API". Also a minor change to the simple rest client moving the header setting to continuation lines rather than showing one long line.
author John Rouillard <rouilj@ieee.org>
date Sun, 14 Apr 2019 21:07:11 -0400
parents e46ce04d5bbc
children 937fff530ccd
line wrap: on
line source

#! /usr/bin/env python
#
# Copyright (C) 2007 Stefan Seefeld
# All rights reserved.
# For license terms see the file COPYING.txt.
#


# --- patch sys.path to make sure 'import roundup' finds correct version
from __future__ import print_function
import sys
import os.path as osp

thisdir = osp.dirname(osp.abspath(__file__))
rootdir = osp.dirname(osp.dirname(thisdir))
if (osp.exists(thisdir + '/__init__.py') and
        osp.exists(rootdir + '/roundup/__init__.py')):
    # the script is located inside roundup source code
    sys.path.insert(0, rootdir)
# --/


import base64, getopt, os, sys, socket
from roundup.anypy import urllib_
from roundup.xmlrpc import translate
from roundup.xmlrpc import RoundupInstance
import roundup.instance
from roundup.instance import TrackerError
from roundup.cgi.exceptions import Unauthorised
from roundup.anypy import xmlrpc_
SimpleXMLRPCServer = xmlrpc_.server.SimpleXMLRPCServer
SimpleXMLRPCRequestHandler = xmlrpc_.server.SimpleXMLRPCRequestHandler


class RequestHandler(SimpleXMLRPCRequestHandler):
    """A SimpleXMLRPCRequestHandler with support for basic
    HTTP Authentication."""

    TRACKER_HOMES = {}
    TRACKERS = {}

    def is_rpc_path_valid(self):
        path = self.path.split('/')
        name = urllib_.unquote(path[1]).lower()
        return name in self.TRACKER_HOMES

    def get_tracker(self, name):
        """Return a tracker instance for given tracker name."""

        if name in self.TRACKERS:
            return self.TRACKERS[name]

        if name not in self.TRACKER_HOMES:
            raise Exception('No such tracker "%s"'%name)
        tracker_home = self.TRACKER_HOMES[name]
        tracker = roundup.instance.open(tracker_home)
        self.TRACKERS[name] = tracker
        return tracker


    def authenticate(self, tracker):

        
        # Try to extract username and password from HTTP Authentication.
        username, password = None, None
        authorization = self.headers.get('authorization', ' ')
        scheme, challenge = authorization.split(' ', 1)

        if scheme.lower() == 'basic':
            decoded = base64.decodestring(challenge)
            if ':' in decoded:
                username, password = decoded.split(':')
            else:
                username = decoded
        if not username:
            username = 'anonymous'
        db = tracker.open('admin')
        try:
            userid = db.user.lookup(username)
        except KeyError: # No such user
            db.close()
            raise Unauthorised('Invalid user')
        stored = db.user.get(userid, 'password')
        if stored != password:
            # Wrong password
            db.close()
            raise Unauthorised('Invalid user')
        db.setCurrentUser(username)
        return db


    def do_POST(self):
        """Extract username and password from authorization header."""

        db = None
        try:
            path = self.path.split('/')
            tracker_name = urllib_.unquote(path[1]).lower()
            tracker = self.get_tracker(tracker_name)
            db = self.authenticate(tracker)

            instance = RoundupInstance(db, tracker.actions, None)
            self.server.register_instance(instance)
            SimpleXMLRPCRequestHandler.do_POST(self)
        except Unauthorised as message:
            self.send_error(403, '%s (%s)'%(self.path, message))
        except:
            if db:
                db.close()
            exc, val, tb = sys.exc_info()
            print(exc, val, tb)
            raise
        if db:
            db.close()


class Server(SimpleXMLRPCServer):

    def _dispatch(self, method, params):

        retn = SimpleXMLRPCServer._dispatch(self, method, params)
        retn = translate(retn)
        return retn


def usage():
    print("""Usage: %s: [options] [name=tracker home]+

Options:
 -e, --encoding    -- specify the encoding to use
 -V                -- be verbose when importing
 -p, --port <port> -- port to listen on

"""%sys.argv[0])

def run():

    try:
        opts, args = getopt.getopt(sys.argv[1:],
                                   'e:i:p:V', ['encoding=', 'port='])
    except getopt.GetoptError as e:
        usage()
        return 1

    verbose = False
    port = 8000
    encoding = None

    for opt, arg in opts:
        if opt == '-V':
            verbose = True
        elif opt in ['-p', '--port']:
            port = int(arg)
        elif opt in ['-e', '--encoding']:
            encoding = encoding

    tracker_homes = {}
    for arg in args:
        try:
            name, home = arg.split('=', 1)
            # Validate the argument
            tracker = roundup.instance.open(home)
        except ValueError:
            print('Instances must be name=home')
            sys.exit(-1)
        except TrackerError:
            print('Tracker home does not exist.')
            sys.exit(-1)

        tracker_homes[name] = home

    RequestHandler.TRACKER_HOMES=tracker_homes

    if sys.version_info[0:2] < (2,5):
        if encoding:
            print('encodings not supported with python < 2.5')
            sys.exit(-1)
        server = Server(('', port), RequestHandler)
    else:
        server = Server(('', port), RequestHandler,
                        allow_none=True, encoding=encoding)

    # Go into the main listener loop
    print('Roundup XMLRPC server started on %s:%d'
          % (socket.gethostname(), port))
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print('Keyboard Interrupt: exiting')

if __name__ == '__main__':
    run()

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