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