http.py 8.44 KB
Newer Older
1 2 3 4 5
"""
Stub implementation of an HTTP service.
"""

from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
6
import urllib
7 8 9
import urlparse
import threading
import json
10
from functools import wraps
11 12 13 14 15 16
from lazy import lazy

from logging import getLogger
LOGGER = getLogger(__name__)


17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
def require_params(method, *required_keys):
    """
    Decorator to ensure that the method has all the required parameters.

    Example:

        @require_params('GET', 'id', 'state')
        def handle_request(self):
            # ....

    would send a 400 response if no GET parameters were specified
    for 'id' or 'state' (or if those parameters had empty values).

    The wrapped function should be a method of a `StubHttpRequestHandler`
    subclass.

    Currently, "GET" and "POST" are the only supported methods.
    """
    def decorator(func):
        @wraps(func)
        def wrapper(self, *args, **kwargs):

            # Read either GET querystring params or POST dict params
            if method == "GET":
                params = self.get_params
            elif method == "POST":
                params = self.post_dict
            else:
                raise ValueError("Unsupported method '{method}'".format(method=method))

            # Check for required values
            missing = []
            for key in required_keys:
                if params.get(key) is None:
                    missing.append(key)

            if len(missing) > 0:
                msg = "Missing required key(s) {keys}".format(keys=",".join(missing))
                self.send_response(400, content=msg, headers={'Content-type': 'text/plain'})

            # If nothing is missing, execute the function as usual
            else:
                return func(self, *args, **kwargs)
        return wrapper
    return decorator


64 65 66 67 68 69 70 71 72 73 74
class StubHttpRequestHandler(BaseHTTPRequestHandler, object):
    """
    Handler for the stub HTTP service.
    """

    protocol = "HTTP/1.0"

    def log_message(self, format_str, *args):
        """
        Redirect messages to keep the test console clean.
        """
75
        LOGGER.debug(self._format_msg(format_str, *args))
76

77 78 79 80 81
    def log_error(self, format_str, *args):
        """
        Helper to log a server error.
        """
        LOGGER.error(self._format_msg(format_str, *args))
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121

    @lazy
    def request_content(self):
        """
        Retrieve the content of the request.
        """
        try:
            length = int(self.headers.getheader('content-length'))

        except (TypeError, ValueError):
            return ""
        else:
            return self.rfile.read(length)

    @lazy
    def post_dict(self):
        """
        Retrieve the request POST parameters from the client as a dictionary.
        If no POST parameters can be interpreted, return an empty dict.
        """
        contents = self.request_content

        # The POST dict will contain a list of values for each key.
        # None of our parameters are lists, however, so we map [val] --> val
        # If the list contains multiple entries, we pick the first one
        try:
            post_dict = urlparse.parse_qs(contents, keep_blank_values=True)
            return {
                key: list_val[0]
                for key, list_val in post_dict.items()
            }

        except:
            return dict()

    @lazy
    def get_params(self):
        """
        Return the GET parameters (querystring in the URL).
        """
122 123 124 125 126
        query = urlparse.urlparse(self.path).query

        # By default, `parse_qs` returns a list of values for each param
        # For convenience, we replace lists of 1 element with just the element
        return {
127 128
            key: value[0] if len(value) == 1 else value
            for key, value in urlparse.parse_qs(query).items()
129 130 131 132 133 134 135 136 137 138 139 140 141
        }

    @lazy
    def path_only(self):
        """
        Return the URL path without GET parameters.
        Removes the trailing slash if there is one.
        """
        path = urlparse.urlparse(self.path).path
        if path.endswith('/'):
            return path[:-1]
        else:
            return path
142 143 144 145

    def do_PUT(self):
        """
        Allow callers to configure the stub server using the /set_config URL.
146 147 148 149
        The request should have POST data, such that:

            Each POST parameter is the configuration key.
            Each POST value is a JSON-encoded string value for the configuration.
150 151 152
        """
        if self.path == "/set_config" or self.path == "/set_config/":

153 154 155 156 157 158 159 160 161 162 163
            if len(self.post_dict) > 0:
                for key, value in self.post_dict.iteritems():

                    # Decode the params as UTF-8
                    try:
                        key = unicode(key, 'utf-8')
                        value = unicode(value, 'utf-8')
                    except UnicodeDecodeError:
                        self.log_message("Could not decode request params as UTF-8")

                    self.log_message(u"Set config '{0}' to '{1}'".format(key, value))
164

165 166
                    try:
                        value = json.loads(value)
167

168 169 170
                    except ValueError:
                        self.log_message(u"Could not parse JSON: {0}".format(value))
                        self.send_response(400)
171

172 173 174 175 176 177 178
                    else:
                        self.server.config[key] = value
                        self.send_response(200)

            # No parameters sent to configure, so return success by default
            else:
                self.send_response(200)
179 180 181 182 183 184 185 186 187 188 189 190 191 192

        else:
            self.send_response(404)

    def send_response(self, status_code, content=None, headers=None):
        """
        Send a response back to the client with the HTTP `status_code` (int),
        `content` (str) and `headers` (dict).
        """
        self.log_message(
            "Sent HTTP response: {0} with content '{1}' and headers {2}".format(status_code, content, headers)
        )

        if headers is None:
193 194 195
            headers = {
                'Access-Control-Allow-Origin': "*",
            }
196 197 198 199 200 201 202 203 204 205 206 207

        BaseHTTPRequestHandler.send_response(self, status_code)

        for (key, value) in headers.items():
            self.send_header(key, value)

        if len(headers) > 0:
            self.end_headers()

        if content is not None:
            self.wfile.write(content)

208 209 210 211 212 213 214
    def send_json_response(self, content):
        """
        Send a response with status code 200, the given content serialized as
        JSON, and the Content-Type header set appropriately
        """
        self.send_response(200, json.dumps(content), {"Content-Type": "application/json"})

215 216 217 218 219 220
    def _format_msg(self, format_str, *args):
        """
        Format message for logging.
        `format_str` is a string with old-style Python format escaping;
        `args` is an array of values to fill into the string.
        """
221 222
        if not args:
            format_str = urllib.unquote(format_str)
223 224 225 226 227 228
        return u"{0} - - [{1}] {2}\n".format(
            self.client_address[0],
            self.log_date_time_string(),
            format_str % args
        )

229 230 231 232 233 234
    def do_HEAD(self):
        """
        Respond to an HTTP HEAD request
        """
        self.send_response(200)

235 236 237 238 239 240 241 242 243 244 245 246 247 248 249

class StubHttpService(HTTPServer, object):
    """
    Stub HTTP service implementation.
    """

    # Subclasses override this to provide the handler class to use.
    # Should be a subclass of `StubHttpRequestHandler`
    HANDLER_CLASS = StubHttpRequestHandler

    def __init__(self, port_num=0):
        """
        Configure the server to listen on localhost.
        Default is to choose an arbitrary open port.
        """
250
        address = ('0.0.0.0', port_num)
251 252 253
        HTTPServer.__init__(self, address, self.HANDLER_CLASS)

        # Create a dict to store configuration values set by the client
254
        self.config = dict()
255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280

        # Start the server in a separate thread
        server_thread = threading.Thread(target=self.serve_forever)
        server_thread.daemon = True
        server_thread.start()

        # Log the port we're using to help identify port conflict errors
        LOGGER.debug('Starting service on port {0}'.format(self.port))

    def shutdown(self):
        """
        Stop the server and free up the port
        """
        # First call superclass shutdown()
        HTTPServer.shutdown(self)

        # We also need to manually close the socket
        self.socket.close()

    @property
    def port(self):
        """
        Return the port that the service is listening on.
        """
        _, port = self.server_address
        return port