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 #!/usr/bin/env python3
import http.client import argparse
import http.server import http.server
import json import json
import os import os
import signal
import socketserver import socketserver
import subprocess
import argparse
import sys import sys
import time 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 # In-memory store for echo responses
echo_store = {} echo_store: Dict[str, Echo] = {}
class TestHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): class TestHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs): def __init__(self, *arguments, **kwargs):
super().__init__(*args, directory=self.static_directory, **kwargs) super().__init__(*arguments, directory=None, **kwargs)
def do_GET(self): def do_GET(self):
if self.path == "/shutdown": if self.path.startswith("/static/"):
self.send_response(200) # Remove "/static/" prefix and use built-in method
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
self.path = self.path[7:] self.path = self.path[7:]
return super().do_GET() return super().do_GET()
else: else:
self.handle_echo() self.handle_echo()
def do_POST(self): def do_POST(self):
if self.path == "/create": if self.path == "/echo":
content_length = int(self.headers["Content-Length"]) content_length = int(self.headers["Content-Length"])
post_data = self.rfile.read(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() echo = Echo()
path = response_def.get("path", "") echo.method = data.get("method", None)
key = f'{method} {path}' 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' is_using_reserved_path = echo.path.startswith("/static") or echo.path.startswith("/echo")
if (is_invalid_path or key in echo_store):
# 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_response(400)
self.send_header("Content-Type", "text/plain") self.send_header("Content-Type", "text/plain")
self.end_headers() 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 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') echo_store[key] = echo
path = path.lstrip('/')
fetch_url = f'http://{host}/{path}' 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 # The params to use on the client when making a request to the newly created echo endpoint
fetch_config = { fetch_config = {
"method": method, "method": echo.method,
"url": fetch_url, "url": fetch_url,
} }
@ -85,7 +98,7 @@ class TestHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
self.handle_echo() self.handle_echo()
def do_OPTIONS(self): def do_OPTIONS(self):
if self.path.startswith("/create"): if self.path.startswith("/echo"):
self.send_response(204) self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "*") self.send_header("Access-Control-Allow-Methods", "*")
@ -105,23 +118,25 @@ class TestHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
def handle_echo(self): def handle_echo(self):
method = self.command.upper() method = self.command.upper()
key = f'{method} {self.path}' key = f"{method} {self.path}"
if key in echo_store: if key in echo_store:
response_def = echo_store[key] echo = echo_store[key]
if "delay" in response_def: if echo.delay_ms is not None:
time.sleep(response_def["delay"]) time.sleep(echo.delay_ms / 1000)
# Send the status code without any default headers # 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 # Set only the headers defined in the echo definition
for header, value in response_def.get("headers", {}).items(): if echo.headers is not None:
self.send_header(header, value) for header, value in echo.headers.items():
self.end_headers() 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: else:
self.send_error(404, f"Echo response not found for {key}") self.send_error(404, f"Echo response not found for {key}")
@ -132,22 +147,12 @@ class TestHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
self.handle_echo() self.handle_echo()
pid_file_path = "http-test-server.pid.txt" def start_server(port, static_directory):
log_file_path = "http-test-server.log"
def run_server(port=8000, static_directory="."):
TestHTTPRequestHandler.static_directory = os.path.abspath(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(httpd.socket.getsockname()[1])
print( sys.stdout.flush()
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()))
try: try:
httpd.serve_forever() httpd.serve_forever()
@ -156,70 +161,9 @@ def run_server(port=8000, static_directory="."):
finally: finally:
httpd.server_close() 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__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run a test HTTP server") parser = argparse.ArgumentParser(description="Run a HTTP echo server")
parser.add_argument(
"-p", "--port", type=int, default=8123, help="Port to run the server on"
)
parser.add_argument( parser.add_argument(
"-d", "-d",
"--directory", "--directory",
@ -228,21 +172,12 @@ if __name__ == "__main__":
help="Directory to serve static files from", help="Directory to serve static files from",
) )
parser.add_argument( parser.add_argument(
"-b", "-p",
"--background", "--port",
action="store_true", type=int,
help="Run the server in the background", default=0,
help="Port to run the server on",
) )
parser.add_argument("action", choices=["start", "stop"], help="Action to perform")
args = parser.parse_args() args = parser.parse_args()
if args.action == "start": start_server(port=args.port, static_directory=args.directory)
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()

View file

@ -104,7 +104,7 @@ class HTTPTestServer {
this.baseURL = baseURL; this.baseURL = baseURL;
} }
async createEcho(method, path, options) { async createEcho(method, path, options) {
const result = await fetch(`${this.baseURL}/create`, { const result = await fetch(`${this.baseURL}/echo`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",