Tests/LibWeb: Refactor HTTP echo server script

This commit makes the following changes:
- Adds a model "Echo" for the request body
- Changes the main endpoint from /create to /echo (more REST-y)
- Sends "400: Bad Request" for invalid params or a reserved path
- Sends "409: Conflict" when trying to use an already registered path
- Prints the server port to stdout
- Removes unnecessary subcommands/options (start, stop, --background)
This commit is contained in:
rmg-x 2024-11-24 15:41:40 -06:00 committed by Andrew Kaster
commit 4dd21d0b80
Notes: github-actions[bot] 2024-12-06 00:09:48 +00:00
2 changed files with 77 additions and 142 deletions

View file

@ -1,76 +1,89 @@
#!/usr/bin/env python3
import http.client
import argparse
import http.server
import json
import os
import signal
import socketserver
import subprocess
import argparse
import sys
import time
from typing import Dict
"""
Description:
This script starts a simple HTTP echo server on localhost for use in our in-tree tests.
The port is assigned by the OS on startup and printed to stdout.
Endpoints:
- POST /echo <json body>, Creates an echo response for later use. See "Echo" class below for body properties.
"""
class Echo:
method: str
path: str
status: int
headers: Dict[str, str] | None
body: str | None
delay_ms: int | None
headers: dict | None
# In-memory store for echo responses
echo_store = {}
echo_store: Dict[str, Echo] = {}
class TestHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=self.static_directory, **kwargs)
def __init__(self, *arguments, **kwargs):
super().__init__(*arguments, directory=None, **kwargs)
def do_GET(self):
if self.path == "/shutdown":
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(b"Goodbye")
self.server.server_close()
print("Goodbye")
sys.exit(0)
elif self.path == "/ping":
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"pong")
elif self.path.startswith("/static/"):
# Remove '/static/' prefix and use built-in method
if self.path.startswith("/static/"):
# Remove "/static/" prefix and use built-in method
self.path = self.path[7:]
return super().do_GET()
else:
self.handle_echo()
def do_POST(self):
if self.path == "/create":
if self.path == "/echo":
content_length = int(self.headers["Content-Length"])
post_data = self.rfile.read(content_length)
response_def = json.loads(post_data.decode("utf-8"))
data = json.loads(post_data.decode("utf-8"))
method = response_def.get("method", "GET").upper()
path = response_def.get("path", "")
key = f'{method} {path}'
echo = Echo()
echo.method = data.get("method", None)
echo.path = data.get("path", None)
echo.status = data.get("status", None)
echo.body = data.get("body", None)
echo.delay_ms = data.get("delay_ms", None)
echo.headers = data.get("headers", None)
is_invalid_path = path.startswith('/static') or path == '/create' or path == '/shutdown' or path == '/ping'
if (is_invalid_path or key in echo_store):
is_using_reserved_path = echo.path.startswith("/static") or echo.path.startswith("/echo")
# Return 400: Bad Request if invalid params are given or a reserved path is given
if echo.method is None or echo.path is None or echo.status is None or is_using_reserved_path:
self.send_response(400)
self.send_header("Content-Type", "text/plain")
self.end_headers()
if is_invalid_path:
self.wfile.write(b"invalid path, must not be /static, /create, /shutdown, /ping")
else:
self.wfile.write(b"invalid path, already registered")
return
echo_store[key] = response_def
# Return 409: Conflict if the method+path combination already exists
key = f"{echo.method} {echo.path}"
if key in echo_store:
self.send_response(409)
self.send_header("Content-Type", "text/plain")
self.end_headers()
return
host = self.headers.get('host', 'localhost')
path = path.lstrip('/')
fetch_url = f'http://{host}/{path}'
echo_store[key] = echo
host = self.headers.get("host", "localhost")
path = echo.path.lstrip("/")
fetch_url = f"http://{host}/{path}"
# The params to use on the client when making a request to the newly created echo endpoint
fetch_config = {
"method": method,
"method": echo.method,
"url": fetch_url,
}
@ -85,7 +98,7 @@ class TestHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
self.handle_echo()
def do_OPTIONS(self):
if self.path.startswith("/create"):
if self.path.startswith("/echo"):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "*")
@ -105,23 +118,25 @@ class TestHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
def handle_echo(self):
method = self.command.upper()
key = f'{method} {self.path}'
key = f"{method} {self.path}"
if key in echo_store:
response_def = echo_store[key]
echo = echo_store[key]
if "delay" in response_def:
time.sleep(response_def["delay"])
if echo.delay_ms is not None:
time.sleep(echo.delay_ms / 1000)
# Send the status code without any default headers
self.send_response_only(response_def.get("status", 200))
self.send_response_only(echo.status)
# Set only the headers defined in the echo definition
for header, value in response_def.get("headers", {}).items():
self.send_header(header, value)
self.end_headers()
if echo.headers is not None:
for header, value in echo.headers.items():
self.send_header(header, value)
self.end_headers()
self.wfile.write(response_def.get("body", "").encode("utf-8"))
response_body = echo.body or ""
self.wfile.write(response_body.encode("utf-8"))
else:
self.send_error(404, f"Echo response not found for {key}")
@ -132,22 +147,12 @@ class TestHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
self.handle_echo()
pid_file_path = "http-test-server.pid.txt"
log_file_path = "http-test-server.log"
def run_server(port=8000, static_directory="."):
def start_server(port, static_directory):
TestHTTPRequestHandler.static_directory = os.path.abspath(static_directory)
httpd = socketserver.TCPServer(("", port), TestHTTPRequestHandler)
httpd = socketserver.TCPServer(("127.0.0.1", port), TestHTTPRequestHandler)
print(f"Serving at http://localhost:{port}/")
print(
f"Serving static files from directory: {TestHTTPRequestHandler.static_directory}"
)
# Save pid to file
with open(pid_file_path, "w") as f:
f.write(str(os.getpid()))
print(httpd.socket.getsockname()[1])
sys.stdout.flush()
try:
httpd.serve_forever()
@ -156,70 +161,9 @@ def run_server(port=8000, static_directory="."):
finally:
httpd.server_close()
print("Goodbye")
def stop_server(quiet=False):
if os.path.exists(pid_file_path):
with open(pid_file_path, "r") as f:
pid = int(f.read().strip())
try:
os.kill(pid, signal.SIGTERM)
os.remove(pid_file_path)
print("Server stopped")
except ProcessLookupError:
print("Server not running")
except PermissionError:
print("Permission denied when trying to stop the server")
elif not quiet:
print("No server running")
def start_server_in_background(port, directory):
# Launch the server as a detached subprocess
with open(log_file_path, "w") as log_file:
stop_server(True)
subprocess.Popen(
[sys.executable, __file__, "start", "-p", str(port), "-d", directory],
stdout=log_file,
stderr=log_file,
preexec_fn=os.setpgrp,
)
# Sleep to give the server time to start
time.sleep(0.05)
# Verify that the server is up by sending a GET request to /ping
max_retries = 3
for i in range(max_retries):
try:
conn = http.client.HTTPConnection("localhost", port, timeout=1)
conn.request("GET", "/ping")
response = conn.getresponse()
if response.status == 200 and response.read().decode().strip() == "pong":
print(f"Server successfully started on port {port}")
return True
except (http.client.HTTPException, ConnectionRefusedError, OSError):
if i < max_retries - 1:
print(
f"Server not ready, retrying in 1 second... (Attempt {i+1}/{max_retries})"
)
time.sleep(1)
else:
print(f"Failed to start server after {max_retries} attempts")
return False
finally:
conn.close()
print(f"Server verification failed after {max_retries} attempts")
return False
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run a test HTTP server")
parser.add_argument(
"-p", "--port", type=int, default=8123, help="Port to run the server on"
)
parser = argparse.ArgumentParser(description="Run a HTTP echo server")
parser.add_argument(
"-d",
"--directory",
@ -228,21 +172,12 @@ if __name__ == "__main__":
help="Directory to serve static files from",
)
parser.add_argument(
"-b",
"--background",
action="store_true",
help="Run the server in the background",
"-p",
"--port",
type=int,
default=0,
help="Port to run the server on",
)
parser.add_argument("action", choices=["start", "stop"], help="Action to perform")
args = parser.parse_args()
if args.action == "start":
if args.background:
# Detach the server and run in the background
start_server_in_background(args.port, args.directory)
print(f"Server started in the background, check '{log_file_path}' for details.")
else:
# Run normally
run_server(port=args.port, static_directory=args.directory)
elif args.action == "stop":
stop_server()
start_server(port=args.port, static_directory=args.directory)