""" 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()