Writing a minimal JSON Webserver using http.server

Introduction

Recently I wrote a small API webservice with the explicit goal of just using just python's built in http.server.

It wasn't very straight forward, as it is really not designed to be a proper web server, but mostly used to serve a single directory, but with a bit of work, it does the job.

Structure

The web server is implemented with a generic class subclassing BaseHTTPRequestHandler, it looks up paths in a dictionary, and calls a class per URL. This class implements the basic GET/POST etc. verbs as functions. If these functions return any non-string object, these objects will be serialized as JSON.

Base class

class BaseJSONHandler(BaseHTTPRequestHandler):
    """Generic handler of HTTP requests"""

    def __init__(self, *args, urlmap=None, **kwargs):
        self.urlmap = {}
        if urlmap is not None:
            self.urlmap = urlmap

        super().__init__(*args, **kwargs)

    def _request(self):
        data = None
        length = int(self.headers.get('content-length', 0))
        if length > 0:
            data = self.rfile.read(length)
        request = {
            "server": self.server,
            "command": self.command,
            "rfile": self.rfile,
            "wfile": self.wfile,
            "data": data,
            "headers": self.headers
        }
        return request

    def send_error(self, code, message=None, explain=None):
        """JSON style override for send_error"""

        try:
            shortmsg, longmsg = self.responses[code]
        except KeyError:
            shortmsg, longmsg = '???', '???'

        if message is None:
            message = shortmsg
        if explain is None:
            explain = longmsg

        self.log_error("code %d, message %s", code, message)
        self.send_response(code, message)
        self.send_header('Connection', 'close')

        body = None

        if (code >= 200 and code not in (
                HTTPStatus.NO_CONTENT,
                HTTPStatus.RESET_CONTENT,
                HTTPStatus.NOT_MODIFIED
        )):

            timestamp = datetime.datetime.utcnow().\
                replace(tzinfo=datetime.timezone.utc).isoformat()
            content = {
                "timestamp": timestamp,
                "status": code,
                "error": message,
                "message": explain,
                "path": self.path
            }
            body = json.dumps(content).encode('utf-8')
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(body)))

        self.end_headers()

        if self.command != 'HEAD' and body:
            self.wfile.write(body)

    def _set_headers(self):
        self.send_response(HTTPStatus.OK.value)
        self.send_header('Content-type', 'application/json')
        # Send CORS for good measures
        self.send_header('Access-Control-Allow-Origin', '*')
        self.end_headers()

    def handle_one_request(self):
        """Handle a single HTTP request.

        This method is overridden to better reuse code"""

        try:
            # pylint: disable=attribute-defined-outside-init
            self.raw_requestline = self.rfile.readline(65537)
            if len(self.raw_requestline) > 65536:
                # pylint: disable=attribute-defined-outside-init
                self.requestline = ''
                self.request_version = ''
                self.command = ''
                self.send_error(HTTPStatus.REQUEST_URI_TOO_LONG)
                return
            if not self.raw_requestline:
                self.close_connection = True
                return
            if not self.parse_request():
                return

            method = self.command

            # Some method should stay local to this file, like OPTIONS and HEAD
            mname = 'do_' + self.command
            if hasattr(self, mname):
                method = getattr(self, mname)
                method()
                self.wfile.flush()
            else:
                self.resolve_request()
                self.wfile.flush()

        except socket.timeout as err:
            # pylint: disable=attribute-defined-outside-init
            self.log_error("Request timed out: %r", err)
            self.close_connection = True

    def resolve_request(self):
        """Pass on request to a class"""
        method = self.command
        url = urlsplit(self.path)
        path = url.path
        mname = 'do_' + self.command

        if path in self.urlmap:
            cls = self.urlmap[path](self)
            request = self._request()
            request['query'] = parse_qs(url.query)
            if hasattr(cls, mname):
                method = getattr(cls, mname)
                try:
                    response = method(request)
                    self._set_headers()
                    if response is not None:
                        if isinstance(response, str):
                            self.wfile.write(response.encode('utf-8'))
                        else:
                            self.wfile.write(
                                json.dumps(response).encode('utf-8')
                            )
                except BadRequestException as err:
                    self.send_error(
                        400,
                        message=err.message,
                        explain=err.explain
                    )
                except MethodNotAllowed as err:
                    self.send_error(
                        405,
                        message=err.message,
                        explain=err.explain
                    )

                # pylint: disable=broad-except
                except Exception as ex:
                    self.send_error(
                        500,
                        message="Unexpected error",
                        explain=ex
                    )

            else:
                self.send_error(
                    405,
                    message="Method Not Allowed",
                    explain=f"Request method '{method}' not allowed"
                )
        else:
            self.send_error(
                404,
                message="Not Found",
                explain="No message available"
            )

    def do_OPTIONS(self):
        """ Send CORS headers on OPTIONS """
        # pylint: disable=invalid-name
        self.send_response(HTTPStatus.NO_CONTENT.value)
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST')
        self.send_header('Access-Control-Allow-Headers', 'content-type')
        self.end_headers()

The parent class uses the handle_one_request method to call a method for each HTTP Verb, like do_GET() or do_POST(). I'm overriding this to look up the path in a dictionary, and instead call the method there.

I'm also implementing a number of shorthand Exception classes that all serve to format my exceptions a certain way. This is because I'm using this to mock another system, and I want my exceptions to be identical to that.

There's no real magic to the child classes responsible for serving the URLs. They are all called with a request dictionary, containing various properties of the HTTP request, such as the query strings, and the payload, if any.

This allows you to specify a class like this:

class HelloWorldController:
    def post(self, request):
        thing = request['query'].get('thing', [])[0]
        if len(thing) == 0:
            thing = "World"

        return {
            "greeting": "Hello",
            "thing": thing
        }

This can then be instantiated like this:

urlmap = {
    "/greet": HelloWorldController
}
handler = partial(BaseJSONHandler, urlmap=urlmap)
httpd = HTTPServer(('', 8080), handler)
httpd.serve_forever()

simple and sweet.

With this class based approach, I'm basically creating infinite extensibility, but I'm sure the bottle neck in http.server will be apparent if you try to do too much with this.

comments powered by Disqus