web-queue-api / server.py
server.py
Raw
"""
Simple HTTP server proxies API requests to a running subprocess, communicating
via stdin and stdout.  Requests to non-API routes are served as static files.
"""

import http.server
import subprocess


# Server hostname and port
HOST = "localhost"
PORT = 8000

# Base URL for the API.  All API calls must start with this path.
API_ROOT = "/api"

# Executable that handles API calls.
API_EXE = "./src/api.exe"


class APIRequestHandler(http.server.SimpleHTTPRequestHandler):
    """
    Python docs
    https://docs.python.org/3.6/library/http.server.html#http.server.SimpleHTTPRequestHandler
    https://docs.python.org/3.6/library/http.server.html?highlight=httprequest#http.server.BaseHTTPRequestHandler
    """

    # Background process is a class variable (like a static member variable in
    # C++ because all instances should share the same subprocess.  Otherwise,
    # the process's datastructures would be "reset" with every request.
    process = None

    def __init__(self, *args, **kwargs):
        """Start the background process that will handle proxied requests."""
        if APIRequestHandler.process is None:
            print("Starting background process {}".format(API_EXE))
            APIRequestHandler.process = subprocess.Popen(
                [API_EXE],
                bufsize=1,
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
            )
        super().__init__(*args, **kwargs)

    def do_GET(self):
        """Handle an HTTP GET request.

        GET requests to API_ROOT are proxied to process stdin.  GET requests to
        all other paths are served as static files.
        """
        if self.path.startswith(API_ROOT):
            # Proxy to process
            self.proxy()
        else:
            # Serve static file
            super().do_GET()

    def do_POST(self):
        """Handle an HTTP POST request by proxying to process stdin."""
        self.proxy()

    def do_DELETE(self):
        """Handle an HTTP DELETE request by proxying to process stdin."""
        self.proxy()

    def proxy(self):
        """Proxy one request to process and proxy one response from process."""
        if not self.path.startswith(API_ROOT):
            self.send_error(
                http.HTTPStatus.METHOD_NOT_ALLOWED,
                "Method not allowed on this path: {}".format(self.path),
            )
            return
        self.proxy_request()
        self.proxy_response()

    def proxy_request(self):
        """Proxy a request from HTTP client to process."""

        # Read body of request from HTTP client
        content_length = int(self.headers.get('content-length', 0))
        host = self.headers.get('host', 'localhost')
        data = self.rfile.read(content_length).decode('utf-8')

        # Remove leading and trailing whitespace from data
        data = data.strip()

        # Build request string, example:
        #
        # GET /api/queue/ HTTP/1.1
        # Host: localhost
        request_str = (
            "{method} {path} {version}\n"
            "Host: {host}\n"
            "Content-Type: application/json; charset=utf-8\n"
            "Content-Length: {content_length}\n"
        ).format(
            method=self.command,
            path=self.path,
            version=self.request_version,
            host=host,
            content_length=len(data),
        )

        # Add data, if any
        if data:
            request_str += "\n"
            request_str += data
            request_str += "\n"

        # Write request to stdin of subprocess
        for line in request_str.split("\n"):
            print("< {}".format(line.strip()))
        APIRequestHandler.process.stdin.write(bytes(str(request_str), 'utf-8'))
        APIRequestHandler.process.stdin.flush()

    def proxy_response(self):
        """Proxy a response from process to HTTP client."""

        # Consume whitespace
        while APIRequestHandler.process.stdout.peek(1).isspace():
            APIRequestHandler.process.stdout.read(1)

        # Parse HTTP Response
        response = BytesHTTPResponse(APIRequestHandler.process.stdout)
        response.begin()
        data = response.read()

        # Print response to debug output
        assert response.version == 11
        print("> HTTP/1.1 {} {}".format(response.status, response.reason))
        print("> Content-Type: {}".format(response.getheader("Content-Type")))
        print("> Content-Length: {}".format(response.getheader("Content-Length")))
        print(">")
        for line in data.decode("utf8").split("\n"):
            print("> {}".format(line.rstrip()))

        # Write response to HTTP client
        self.send_response(response.status)
        self.send_header('Content-type', 'application/json')
        self.end_headers()
        self.wfile.write(data)


class BytesHTTPResponse(http.client.HTTPResponse):
    """Parse an HTTP response from a byte string.

    EXAMPLE:
    response = BytesHTTPResponse(b'''HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    Content-Length: 161

    {
        "queue_head_url": "http://localhost/queue/head/",
        "queue_list_url": "http://localhost/queue/",
        "queue_tail_url": "http://localhost/queue/tail/"
    }

    ''')
    response.begin())
    print(response.version)
    print(response.status)
    print(response.reason)
    print(response.getheaders())
    print(response.read())
    """

    def __init__(self, response_bytes):
        """Create an HTTP Response using a fake underlying socket."""

        class FakeSocket():
            """Adapt stream of bytes interface to Socket interface."""
            def __init__(self, response_bytes):
                self._file = response_bytes

            def makefile(self, *args, **kwargs):
                return self._file

        # Create a data stream from a byte string.  This data stream will act
        # like a socket via the FakeSocket object.
        sock = FakeSocket(response_bytes)

        # Let super class do the hard work
        super().__init__(sock)

    def _close_conn(self):
        """Never close the connection (does nothing)."""


def main():
    """Start a server."""
    print("Starting server on {}:{}".format(HOST, PORT))
    server = http.server.HTTPServer((HOST, PORT), APIRequestHandler)
    server.serve_forever()


if __name__ == "__main__":
    main()