Files
ladybird/Tests/LibWeb/Fixtures/http-test-server.py
Timothy Flynn 453764d3f0 LibHTTP: Do not respond to Range requests with cached full responses
If we have the response for a non-Range request in the memory cache, we
would previously use it in reply to Range requests. Similar to commit
878b00ae61f998a26aad7f50fae66cf969878ad6, we are just punting on Range
requests in the HTTP caches for now.
2026-01-10 09:02:41 -05:00

246 lines
8.1 KiB
Python
Executable File

#!/usr/bin/env python3
import argparse
import http.server
import json
import os
import socket
import socketserver
import sys
import time
from collections import defaultdict
from typing import Dict
from typing import Optional
"""
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]
body: Optional[str]
delay_ms: Optional[int]
reason_phrase: Optional[str]
reflect_headers_in_body: bool
# In-memory store for echo responses
echo_store: Dict[str, Echo] = {}
class TestHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *arguments, **kwargs):
super().__init__(*arguments, directory=None, **kwargs)
def do_GET(self):
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 == "/echo":
content_length = int(self.headers["Content-Length"])
post_data = self.rfile.read(content_length)
data = json.loads(post_data.decode("utf-8"))
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", {})
echo.reason_phrase = data.get("reason_phrase", None)
echo.reflect_headers_in_body = data.get("reflect_headers_in_body", False)
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 (echo.body is not None and echo.reflect_headers_in_body)
or is_using_reserved_path
):
self.send_response(400)
self.send_header("Content-Type", "text/plain")
self.end_headers()
return
# 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
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": echo.method,
"url": fetch_url,
}
self.send_response(201)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(fetch_config).encode("utf-8"))
elif self.path.startswith("/static/"):
self.send_error(405, "Method Not Allowed")
else:
self.handle_echo()
def do_OPTIONS(self):
if self.path.startswith("/echo"):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "*")
self.send_header("Access-Control-Allow-Headers", "*")
self.end_headers()
else:
self.do_other()
def do_PUT(self):
self.do_other()
def do_HEAD(self):
self.do_other()
def do_DELETE(self):
self.do_other()
def handle_echo(self):
method = self.command.upper()
key = f"{method} {self.path}"
is_revalidation_request = "If-Modified-Since" in self.headers
send_not_modified = is_revalidation_request and "X-Ladybird-Respond-With-Not-Modified" in self.headers
send_incomplete_response = "X-Ladybird-Respond-With-Incomplete-Response" in self.headers
if key in echo_store:
echo = echo_store[key]
response_headers = echo.headers.copy()
if echo.delay_ms is not None:
time.sleep(echo.delay_ms / 1000)
if send_not_modified:
self.send_response(304)
else:
self.send_response_only(echo.status, echo.reason_phrase)
if is_revalidation_request:
# Override the Last-Modified header to prevent cURL from thinking the response is still fresh.
response_headers["Last-Modified"] = "Thu, 01 Jan 1970 00:00:00 GMT"
elif send_incomplete_response:
# We emulate an incomplete response by advertising a 10KB file, but only sending 2KB.
response_headers["Content-Length"] = str(10 * 1024)
# Set only the headers defined in the echo definition
if response_headers:
for header, value in response_headers.items():
self.send_header(header, value)
self.end_headers()
if send_not_modified:
return
if send_incomplete_response:
self.wfile.write(b"a" * (2 * 1024))
self.wfile.flush()
self.connection.shutdown(socket.SHUT_WR)
self.connection.close()
return
if echo.reflect_headers_in_body:
headers = defaultdict(list)
for key in self.headers.keys():
headers[key] = self.headers.get_all(key)
response_body = json.dumps(headers)
else:
response_body = echo.body or ""
# FIXME: This only supports "Range: bytes=start-end" and "Range: bytes=start-". There are other formats to
# support if needed: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Range#syntax
if "Range" in self.headers:
range_value = self.headers["Range"].strip()
assert range_value.startswith("bytes=")
assert range_value.count("-") == 1
range_value = range_value[len("bytes=") :]
start, end = range_value.split("-")
if end:
response_body = response_body[int(start) : min(int(end), len(response_body))]
else:
response_body = response_body[int(start) :]
self.wfile.write(response_body.encode("utf-8"))
else:
self.send_error(404, f"Echo response not found for {key}")
def do_other(self):
if self.path.startswith("/static/"):
self.send_error(405, "Method Not Allowed")
else:
self.handle_echo()
def start_server(port, static_directory):
TestHTTPRequestHandler.static_directory = os.path.abspath(static_directory)
httpd = socketserver.TCPServer(("127.0.0.1", port), TestHTTPRequestHandler)
print(httpd.socket.getsockname()[1])
sys.stdout.flush()
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
finally:
httpd.server_close()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run a HTTP echo server")
parser.add_argument(
"-d",
"--directory",
type=str,
default=".",
help="Directory to serve static files from",
)
parser.add_argument(
"-p",
"--port",
type=int,
default=0,
help="Port to run the server on",
)
args = parser.parse_args()
start_server(port=args.port, static_directory=args.directory)