feat(http1): add an HTTP/1.1 client and listener
HTTP/3-only is not a deployable position yet: plenty of clients, proxies and CI tooling still speak nothing but HTTP/1.1. This adds that path using the request/response types the HTTP/3 stack already uses, so a route handler or call site moves between the two protocols by changing the class name. - :HTTP1 — transport-free wire format. Serialisation with the framing headers owned by the serialiser, and an incremental parser that takes arbitrary socket chunks and yields one message at a time: keep-alive, pipelining, content-length and chunked bodies (with trailers), read-to-EOF responses, interim 1xx skipping, HEAD/204/304 framing and Expect: 100-continue. Ambiguous framing is rejected rather than guessed at (content-length with transfer-encoding, disagreeing content-lengths, whitespace before a colon), and CR/LF in a value we are asked to serialise is refused. - ClientHTTP1 — persistent connection, redialling once when a pooled connection turns out to have been closed by the peer, which is the race HTTP/1.1 keep-alive cannot avoid. Nothing is replayed after a response byte has arrived. - ListenerHTTP1 — one thread per connection (keep-alive connections are idle most of their life and would pin every ThreadPool thread), automatic Date, HEAD, 100-continue, handler-requested close, idle and request timeouts, and 400/404/500 responses. Routes fall back to the query-stripped path so `/thing?x=1` reaches the handler for `/thing`. No TLS: this is `http://` only. Encrypted traffic still goes over HTTP/3, or through a TLS-terminating proxy. Tests: codec unit tests including the malformed inputs above, a client/server round-trip, keep-alive and stale-connection recovery, a 10 MiB body both ways, and interop both directions against curl and python3's http.server (skipped when those are not installed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b758419007
commit
337ce32eca
12 changed files with 2175 additions and 4 deletions
233
tests/ShouldInteropCurlHTTP1/main.cpp
Normal file
233
tests/ShouldInteropCurlHTTP1/main.cpp
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// Interop against implementations that are not this library — the whole
|
||||
// point of shipping HTTP/1.1 in the first place.
|
||||
//
|
||||
// * curl drives ListenerHTTP1: keep-alive reuse, chunked upload,
|
||||
// Expect: 100-continue, HEAD, and a plain GET.
|
||||
// * ClientHTTP1 drives python3's http.server, which answers HTTP/1.0 with
|
||||
// `Connection: close` — the legacy shape our own listener never emits.
|
||||
//
|
||||
// Both peers are optional: if curl or python3 is missing the corresponding
|
||||
// half is skipped rather than failed, so the suite still runs on a bare
|
||||
// machine.
|
||||
|
||||
#include <signal.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/wait.h>
|
||||
#include <stdio.h>
|
||||
|
||||
import Crafter.Network;
|
||||
import std;
|
||||
using namespace Crafter;
|
||||
|
||||
namespace {
|
||||
int failures = 0;
|
||||
|
||||
void Check(bool condition, std::string_view what) {
|
||||
if (!condition) {
|
||||
std::println("FAIL: {}", what);
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
bool HaveCommand(std::string_view name) {
|
||||
const std::string probe = "command -v " + std::string(name) + " >/dev/null 2>&1";
|
||||
return std::system(probe.c_str()) == 0;
|
||||
}
|
||||
|
||||
// Run a command and return its stdout. stderr is folded in so a curl
|
||||
// failure explains itself in the test output.
|
||||
std::string Run(const std::string& command) {
|
||||
std::string output;
|
||||
FILE* pipe = popen((command + " 2>&1").c_str(), "r");
|
||||
if (pipe == nullptr) return output;
|
||||
char buffer[4096];
|
||||
while (std::size_t read = std::fread(buffer, 1, sizeof(buffer), pipe)) {
|
||||
output.append(buffer, read);
|
||||
}
|
||||
pclose(pipe);
|
||||
return output;
|
||||
}
|
||||
|
||||
// A child process, killed when this goes out of scope.
|
||||
class Child {
|
||||
public:
|
||||
explicit Child(std::vector<std::string> argv) {
|
||||
std::vector<char*> raw;
|
||||
for (auto& argument : argv) raw.push_back(argument.data());
|
||||
raw.push_back(nullptr);
|
||||
pid = fork();
|
||||
if (pid == 0) {
|
||||
// Keep the test output clean; the child's chatter is not
|
||||
// interesting unless it fails to start, which shows up as a
|
||||
// connection failure instead.
|
||||
freopen("/dev/null", "w", stdout);
|
||||
freopen("/dev/null", "w", stderr);
|
||||
execvp(raw[0], raw.data());
|
||||
_exit(127);
|
||||
}
|
||||
}
|
||||
~Child() {
|
||||
if (pid > 0) {
|
||||
kill(pid, SIGTERM);
|
||||
int status = 0;
|
||||
waitpid(pid, &status, 0);
|
||||
}
|
||||
}
|
||||
Child(const Child&) = delete;
|
||||
bool Started() const { return pid > 0; }
|
||||
|
||||
private:
|
||||
pid_t pid = -1;
|
||||
};
|
||||
|
||||
// Poll until something accepts on the port, so the test doesn't race a
|
||||
// slow-starting server.
|
||||
bool WaitForPort(std::uint16_t port, std::chrono::milliseconds budget) {
|
||||
const auto deadline = std::chrono::steady_clock::now() + budget;
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
try {
|
||||
ClientTCP probe("localhost", port);
|
||||
return true;
|
||||
} catch (const std::exception&) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(25));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void CurlAgainstListener() {
|
||||
if (!HaveCommand("curl")) {
|
||||
std::println("skipping the curl half: curl is not installed");
|
||||
return;
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
|
||||
routes["/hello"] = [](const HTTPRequest&) {
|
||||
return CreateResponseHTTP("200", {{"content-type", "text/plain"}}, "Hello curl!");
|
||||
};
|
||||
routes["/echo"] = [](const HTTPRequest& request) {
|
||||
return CreateResponseHTTP("200", request.method + ":" + request.body);
|
||||
};
|
||||
routes["/agent"] = [](const HTTPRequest& request) {
|
||||
auto agent = request.headers.find("user-agent");
|
||||
return CreateResponseHTTP("200", agent == request.headers.end() ? "none" : agent->second);
|
||||
};
|
||||
|
||||
ListenerAsyncHTTP1 listener(8093, std::move(routes));
|
||||
Check(WaitForPort(8093, std::chrono::seconds(2)), "the HTTP/1.1 listener came up");
|
||||
|
||||
const std::string base = "http://localhost:8093";
|
||||
|
||||
Check(Run("curl -sS --http1.1 " + base + "/hello") == "Hello curl!", "curl GET");
|
||||
|
||||
// Two URLs in one invocation: curl reuses the connection, which
|
||||
// only works if our framing let it know the first response ended.
|
||||
const std::uint64_t before = listener.listener.AcceptedCount();
|
||||
const std::string both = Run("curl -sS --http1.1 " + base + "/hello " + base + "/hello");
|
||||
Check(both == "Hello curl!Hello curl!", "curl got both responses");
|
||||
Check(listener.listener.AcceptedCount() == before + 1, "curl reused one connection for both");
|
||||
|
||||
Check(Run("curl -sS --http1.1 -d 'body text' " + base + "/echo") == "POST:body text",
|
||||
"curl POST with content-length");
|
||||
|
||||
// Chunked upload — curl streams stdin with Transfer-Encoding:
|
||||
// chunked when it can't know the length up front.
|
||||
Check(Run("printf 'streamed body' | curl -sS --http1.1 -H 'Transfer-Encoding: chunked' "
|
||||
"--data-binary @- " + base + "/echo") == "POST:streamed body",
|
||||
"curl chunked upload");
|
||||
|
||||
// A body over 1 KiB makes curl wait for `100 Continue` before it
|
||||
// sends anything. Run it verbosely so the trace proves the interim
|
||||
// response actually went out — without it curl still recovers after
|
||||
// a one-second stall, which would hide the bug.
|
||||
const std::string large(64 * 1024, 'x');
|
||||
const std::string upload =
|
||||
"head -c 65536 /dev/zero | tr '\\0' 'x' | curl -sS --http1.1 "
|
||||
"-H 'Expect: 100-continue' --data-binary @- " + base + "/echo";
|
||||
// The trace goes to stderr and the body to stdout; keeping them in
|
||||
// separate runs avoids the two streams interleaving in the pipe.
|
||||
Check(Run(upload + " -v -o /dev/null").find("HTTP/1.1 100 Continue") != std::string::npos,
|
||||
"curl saw the interim 100 Continue");
|
||||
Check(Run(upload) == "POST:" + large, "curl Expect: 100-continue upload arrived intact");
|
||||
|
||||
// HEAD must produce the GET headers and no body.
|
||||
const std::string head = Run("curl -sS --http1.1 -I " + base + "/hello");
|
||||
Check(head.find("HTTP/1.1 200 OK") != std::string::npos, "curl HEAD status line");
|
||||
Check(head.find("content-length: 11") != std::string::npos, "curl HEAD keeps content-length");
|
||||
Check(head.find("Hello curl!") == std::string::npos, "curl HEAD carries no body");
|
||||
|
||||
Check(Run("curl -sS --http1.1 -A 'crafter-test/1.0' " + base + "/agent") == "crafter-test/1.0",
|
||||
"request headers reach the handler");
|
||||
|
||||
// The status line has to be readable by a real client, not just by
|
||||
// our own parser.
|
||||
Check(Run("curl -sS --http1.1 -o /dev/null -w '%{http_code}' " + base + "/missing") == "404",
|
||||
"curl reads the 404 status");
|
||||
|
||||
listener.Stop();
|
||||
}
|
||||
|
||||
void ClientAgainstPythonServer() {
|
||||
if (!HaveCommand("python3")) {
|
||||
std::println("skipping the python half: python3 is not installed");
|
||||
return;
|
||||
}
|
||||
|
||||
const std::filesystem::path root =
|
||||
std::filesystem::temp_directory_path() / "crafter-network-http1-interop";
|
||||
std::filesystem::create_directories(root);
|
||||
const std::string content = "served by python\n";
|
||||
{
|
||||
std::ofstream file(root / "hello.txt", std::ios::binary);
|
||||
file << content;
|
||||
}
|
||||
|
||||
// http.server answers HTTP/1.0 with `Connection: close`: every
|
||||
// request needs its own connection, and the client has to notice.
|
||||
Child server({"python3", "-m", "http.server", "8094", "--bind", "127.0.0.1",
|
||||
"--directory", root.string()});
|
||||
Check(server.Started(), "python3 http.server was spawned");
|
||||
if (!WaitForPort(8094, std::chrono::seconds(10))) {
|
||||
std::println("skipping the python half: http.server never came up");
|
||||
return;
|
||||
}
|
||||
|
||||
ClientHTTP1 client("localhost", 8094);
|
||||
HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/hello.txt", "localhost:8094"));
|
||||
Check(response.status == "200", "python GET status");
|
||||
Check(response.body == content, "python GET body");
|
||||
Check(!client.Connected(), "an HTTP/1.0 response closes the connection");
|
||||
|
||||
HTTPResponse listing = client.Send(CreateRequestHTTP("GET", "/", "localhost:8094"));
|
||||
Check(listing.status == "200", "python directory listing status");
|
||||
Check(listing.body.find("hello.txt") != std::string::npos, "python directory listing body");
|
||||
|
||||
HTTPResponse missing = client.Send(CreateRequestHTTP("GET", "/nothing-here", "localhost:8094"));
|
||||
Check(missing.status == "404", "python 404");
|
||||
|
||||
HTTPResponse head = client.Send(CreateRequestHTTP("HEAD", "/hello.txt", "localhost:8094"));
|
||||
Check(head.status == "200", "python HEAD status");
|
||||
Check(head.body.empty(), "python HEAD has no body");
|
||||
|
||||
std::filesystem::remove_all(root);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
try {
|
||||
CurlAgainstListener();
|
||||
ClientAgainstPythonServer();
|
||||
} catch (const std::exception& error) {
|
||||
std::println("threw: {}", error.what());
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (failures != 0) {
|
||||
std::println("{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
294
tests/ShouldParseHTTP1/main.cpp
Normal file
294
tests/ShouldParseHTTP1/main.cpp
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// Wire-format unit tests for the HTTP/1.1 codec. No sockets involved — the
|
||||
// parser is fed the same byte sequences a peer would send, including the
|
||||
// malformed ones we are supposed to refuse.
|
||||
|
||||
import Crafter.Network;
|
||||
import std;
|
||||
using namespace Crafter;
|
||||
|
||||
namespace {
|
||||
int failures = 0;
|
||||
|
||||
void Check(bool condition, std::string_view what) {
|
||||
if (!condition) {
|
||||
std::println("FAIL: {}", what);
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
void CheckEqual(const T& actual, const T& expected, std::string_view what) {
|
||||
if (!(actual == expected)) {
|
||||
std::println("FAIL: {} — expected '{}', got '{}'", what, expected, actual);
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
// Feed the message one byte at a time: any parser that only works on
|
||||
// whole-message reads falls over here, and real sockets do split
|
||||
// messages at arbitrary offsets.
|
||||
HTTP1::MessageParser ParseByteByByte(HTTP1::MessageKind kind, std::string_view wire) {
|
||||
HTTP1::MessageParser parser(kind);
|
||||
for (char c : wire) parser.Feed(&c, 1);
|
||||
return parser;
|
||||
}
|
||||
|
||||
bool Rejects(HTTP1::MessageKind kind, std::string_view wire) {
|
||||
try {
|
||||
HTTP1::MessageParser parser(kind);
|
||||
parser.Feed(wire.data(), wire.size());
|
||||
parser.Finish();
|
||||
return false;
|
||||
} catch (const HTTP1::HTTP1ProtocolError&) {
|
||||
return true;
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void RequestRoundTrip() {
|
||||
auto parser = ParseByteByByte(HTTP1::MessageKind::Request,
|
||||
"POST /submit?id=7 HTTP/1.1\r\n"
|
||||
"Host: example.test:8080\r\n"
|
||||
"Content-Length: 5\r\n"
|
||||
"X-Multi: a\r\n"
|
||||
"X-Multi: b\r\n"
|
||||
"\r\n"
|
||||
"hello");
|
||||
Check(parser.Complete(), "request completes");
|
||||
Check(parser.KeepAlive(), "HTTP/1.1 defaults to keep-alive");
|
||||
HTTPRequest request = parser.TakeRequest();
|
||||
CheckEqual<std::string>(request.method, "POST", "method");
|
||||
CheckEqual<std::string>(request.path, "/submit?id=7", "target keeps its query string");
|
||||
CheckEqual<std::string>(request.authority, "example.test:8080", "host becomes authority");
|
||||
CheckEqual<std::string>(request.body, "hello", "body");
|
||||
Check(!request.headers.contains("host"), "host is not duplicated into the header map");
|
||||
CheckEqual<std::string>(request.headers.at("x-multi"), "a, b", "repeated fields are joined");
|
||||
}
|
||||
|
||||
void HeaderNamesAreCaseInsensitive() {
|
||||
HTTP1::MessageParser parser(HTTP1::MessageKind::Request);
|
||||
std::string_view wire = "GET / HTTP/1.1\r\nHOST: h\r\nContent-Type: text/plain\r\n\r\n";
|
||||
parser.Feed(wire.data(), wire.size());
|
||||
Check(parser.Complete(), "bodyless request completes at the blank line");
|
||||
HTTPRequest request = parser.TakeRequest();
|
||||
CheckEqual<std::string>(request.headers.at("content-type"), "text/plain",
|
||||
"field names are lowercased");
|
||||
}
|
||||
|
||||
void ChunkedBodyWithTrailers() {
|
||||
auto parser = ParseByteByByte(HTTP1::MessageKind::Request,
|
||||
"PUT /upload HTTP/1.1\r\n"
|
||||
"Host: h\r\n"
|
||||
"Transfer-Encoding: chunked\r\n"
|
||||
"\r\n"
|
||||
"5\r\nhello\r\n"
|
||||
"6;ext=1\r\n world\r\n"
|
||||
"0\r\n"
|
||||
"X-Checksum: 42\r\n"
|
||||
"\r\n");
|
||||
Check(parser.Complete(), "chunked request completes at the zero chunk");
|
||||
HTTPRequest request = parser.TakeRequest();
|
||||
CheckEqual<std::string>(request.body, "hello world", "chunks are reassembled");
|
||||
CheckEqual<std::string>(request.headers.at("x-checksum"), "42", "trailers are merged in");
|
||||
}
|
||||
|
||||
void PipelinedRequests() {
|
||||
HTTP1::MessageParser parser(HTTP1::MessageKind::Request);
|
||||
std::string_view wire = "GET /one HTTP/1.1\r\nHost: h\r\n\r\nGET /two HTTP/1.1\r\nHost: h\r\n\r\n";
|
||||
parser.Feed(wire.data(), wire.size());
|
||||
Check(parser.Complete(), "first pipelined request is complete");
|
||||
CheckEqual<std::string>(parser.TakeRequest().path, "/one", "first target");
|
||||
parser.Reset();
|
||||
Check(parser.Complete(), "second request was already buffered");
|
||||
CheckEqual<std::string>(parser.TakeRequest().path, "/two", "second target");
|
||||
}
|
||||
|
||||
void ExpectContinue() {
|
||||
HTTP1::MessageParser parser(HTTP1::MessageKind::Request);
|
||||
std::string_view head = "POST / HTTP/1.1\r\nHost: h\r\nExpect: 100-continue\r\nContent-Length: 2\r\n\r\n";
|
||||
parser.Feed(head.data(), head.size());
|
||||
Check(parser.ExpectsContinue(), "peer is waiting for 100 Continue");
|
||||
parser.ContinueSent();
|
||||
Check(!parser.ExpectsContinue(), "expectation clears once answered");
|
||||
parser.Feed("hi", 2);
|
||||
Check(parser.Complete(), "body arrives after the interim response");
|
||||
}
|
||||
|
||||
void ConnectionCloseAndHTTP10() {
|
||||
HTTP1::MessageParser closing(HTTP1::MessageKind::Request);
|
||||
std::string_view wire = "GET / HTTP/1.1\r\nHost: h\r\nConnection: close\r\n\r\n";
|
||||
closing.Feed(wire.data(), wire.size());
|
||||
Check(!closing.KeepAlive(), "Connection: close ends the connection");
|
||||
|
||||
HTTP1::MessageParser legacy(HTTP1::MessageKind::Request);
|
||||
std::string_view old = "GET / HTTP/1.0\r\nHost: h\r\n\r\n";
|
||||
legacy.Feed(old.data(), old.size());
|
||||
Check(!legacy.KeepAlive(), "HTTP/1.0 defaults to closing");
|
||||
|
||||
HTTP1::MessageParser legacyKeep(HTTP1::MessageKind::Request);
|
||||
std::string_view oldKeep = "GET / HTTP/1.0\r\nHost: h\r\nConnection: keep-alive\r\n\r\n";
|
||||
legacyKeep.Feed(oldKeep.data(), oldKeep.size());
|
||||
Check(legacyKeep.KeepAlive(), "HTTP/1.0 reuses when asked explicitly");
|
||||
}
|
||||
|
||||
void ResponseFramings() {
|
||||
// Interim responses must not be mistaken for the real one.
|
||||
auto early = ParseByteByByte(HTTP1::MessageKind::Response,
|
||||
"HTTP/1.1 103 Early Hints\r\nLink: </s.css>\r\n\r\n"
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok");
|
||||
Check(early.Complete(), "response after 103 completes");
|
||||
HTTPResponse response = early.TakeResponse();
|
||||
CheckEqual<std::string>(response.status, "200", "1xx is skipped");
|
||||
CheckEqual<std::string>(response.body, "ok", "final body");
|
||||
Check(!response.headers.contains("link"), "interim headers do not leak into the response");
|
||||
|
||||
// No content-length and no chunking: the body ends at EOF.
|
||||
HTTP1::MessageParser untilClose(HTTP1::MessageKind::Response);
|
||||
std::string_view wire = "HTTP/1.1 200 OK\r\n\r\nstreamed";
|
||||
untilClose.Feed(wire.data(), wire.size());
|
||||
Check(!untilClose.Complete(), "close-framed body is not complete before EOF");
|
||||
untilClose.Finish();
|
||||
Check(untilClose.Complete(), "EOF ends a close-framed body");
|
||||
Check(!untilClose.KeepAlive(), "a close-framed response cannot be followed by another");
|
||||
CheckEqual<std::string>(untilClose.TakeResponse().body, "streamed", "close-framed body");
|
||||
|
||||
// A HEAD response carries the length the body would have had.
|
||||
HTTP1::MessageParser head(HTTP1::MessageKind::Response);
|
||||
head.SetRequestMethod("HEAD");
|
||||
std::string_view headWire = "HTTP/1.1 200 OK\r\nContent-Length: 1234\r\n\r\n";
|
||||
head.Feed(headWire.data(), headWire.size());
|
||||
Check(head.Complete(), "HEAD response has no body to wait for");
|
||||
CheckEqual<std::string>(head.TakeResponse().headers.at("content-length"), "1234",
|
||||
"HEAD keeps the advertised length");
|
||||
|
||||
// 204 likewise.
|
||||
HTTP1::MessageParser noContent(HTTP1::MessageKind::Response);
|
||||
std::string_view noContentWire = "HTTP/1.1 204 No Content\r\n\r\n";
|
||||
noContent.Feed(noContentWire.data(), noContentWire.size());
|
||||
Check(noContent.Complete(), "204 has no body");
|
||||
}
|
||||
|
||||
void AbsoluteFormTarget() {
|
||||
HTTP1::MessageParser parser(HTTP1::MessageKind::Request);
|
||||
std::string_view wire = "GET http://proxy.test/page HTTP/1.1\r\nHost: ignored\r\n\r\n";
|
||||
parser.Feed(wire.data(), wire.size());
|
||||
HTTPRequest request = parser.TakeRequest();
|
||||
CheckEqual<std::string>(request.path, "/page", "absolute-form path");
|
||||
CheckEqual<std::string>(request.authority, "proxy.test", "absolute-form authority wins over Host");
|
||||
CheckEqual<std::string>(request.scheme, "http", "absolute-form scheme");
|
||||
}
|
||||
|
||||
void RejectsMalformedMessages() {
|
||||
Check(Rejects(HTTP1::MessageKind::Request,
|
||||
"GET / HTTP/1.1\r\nHost: h\r\nContent-Length: 4\r\nTransfer-Encoding: chunked\r\n\r\n"),
|
||||
"content-length together with transfer-encoding is refused");
|
||||
Check(Rejects(HTTP1::MessageKind::Request,
|
||||
"GET / HTTP/1.1\r\nHost: h\r\nContent-Length: 1\r\nContent-Length: 2\r\n\r\n"),
|
||||
"disagreeing content-lengths are refused");
|
||||
Check(Rejects(HTTP1::MessageKind::Request, "GET / HTTP/1.1\r\nHost : h\r\n\r\n"),
|
||||
"whitespace before the colon is refused");
|
||||
Check(Rejects(HTTP1::MessageKind::Request, "GET / HTTP/1.1\r\nHost: h\r\nContent-Length: x\r\n\r\n"),
|
||||
"non-numeric content-length is refused");
|
||||
Check(Rejects(HTTP1::MessageKind::Request, "GET / HTTP/2.0\r\nHost: h\r\n\r\n"),
|
||||
"unknown version is refused");
|
||||
Check(Rejects(HTTP1::MessageKind::Request, "GET /\r\n\r\n"),
|
||||
"HTTP/0.9 simple-request is refused");
|
||||
Check(Rejects(HTTP1::MessageKind::Request,
|
||||
"POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 10\r\n\r\nshort"),
|
||||
"a truncated body is refused");
|
||||
Check(Rejects(HTTP1::MessageKind::Response, "HTTP/1.1 2000 Huh\r\n\r\n"),
|
||||
"a four-digit status is refused");
|
||||
}
|
||||
|
||||
void RespectsLimits() {
|
||||
HTTP1::MessageLimits limits;
|
||||
limits.maxBody = 8;
|
||||
HTTP1::MessageParser parser(HTTP1::MessageKind::Request, limits);
|
||||
std::string_view wire = "POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 9\r\n\r\n123456789";
|
||||
bool threw = false;
|
||||
try {
|
||||
parser.Feed(wire.data(), wire.size());
|
||||
} catch (const HTTP1::HTTP1ProtocolError&) {
|
||||
threw = true;
|
||||
}
|
||||
Check(threw, "a body over the limit is refused");
|
||||
}
|
||||
|
||||
void Serialisation() {
|
||||
HTTPRequest request = CreateRequestHTTP("GET", "/x", "example.test");
|
||||
request.headers["Accept"] = "text/plain";
|
||||
const std::string wire = HTTP1::SerializeRequest(request);
|
||||
Check(wire.starts_with("GET /x HTTP/1.1\r\n"), "request line");
|
||||
Check(wire.find("host: example.test\r\n") != std::string::npos, "host header is emitted");
|
||||
Check(wire.find("accept: text/plain\r\n") != std::string::npos, "headers are lowercased");
|
||||
Check(wire.find("content-length") == std::string::npos, "no content-length on a bodyless GET");
|
||||
Check(wire.ends_with("\r\n\r\n"), "header section is terminated");
|
||||
|
||||
HTTPResponse response = CreateResponseHTTP("404", "nope");
|
||||
const std::string responseWire = HTTP1::SerializeResponse(response, { .keepAlive = false });
|
||||
Check(responseWire.starts_with("HTTP/1.1 404 Not Found\r\n"), "status line carries a reason phrase");
|
||||
Check(responseWire.find("content-length: 4\r\n") != std::string::npos, "content-length is derived");
|
||||
Check(responseWire.find("connection: close\r\n") != std::string::npos, "close is announced");
|
||||
Check(responseWire.ends_with("\r\n\r\nnope"), "body follows the header section");
|
||||
|
||||
// A 204 must not advertise a body at all.
|
||||
const std::string empty = HTTP1::SerializeResponse(CreateResponseHTTP("204"));
|
||||
Check(empty.find("content-length") == std::string::npos, "204 carries no content-length");
|
||||
|
||||
// Header injection through a value must not be possible.
|
||||
HTTPResponse injected = CreateResponseHTTP("200", "x");
|
||||
injected.headers["x-evil"] = "a\r\nSet-Cookie: pwned=1";
|
||||
bool threw = false;
|
||||
try {
|
||||
(void)HTTP1::SerializeResponse(injected);
|
||||
} catch (const HTTP1::HTTP1ProtocolError&) {
|
||||
threw = true;
|
||||
}
|
||||
Check(threw, "CRLF in a header value is refused");
|
||||
}
|
||||
|
||||
// A request we serialise must be a request we parse back identically —
|
||||
// the two halves of the codec are used against each other by every
|
||||
// client/server pair in this library.
|
||||
void SerializeThenParse() {
|
||||
HTTPRequest original = CreateRequestHTTP("POST", "/echo", "host.test:81",
|
||||
{{"content-type", "application/json"}},
|
||||
std::string("{\"a\":1}"));
|
||||
const std::string wire = HTTP1::SerializeRequest(original);
|
||||
HTTP1::MessageParser parser(HTTP1::MessageKind::Request);
|
||||
parser.Feed(wire.data(), wire.size());
|
||||
Check(parser.Complete(), "serialised request parses back");
|
||||
HTTPRequest parsed = parser.TakeRequest();
|
||||
CheckEqual(parsed.method, original.method, "round-trip method");
|
||||
CheckEqual(parsed.path, original.path, "round-trip path");
|
||||
CheckEqual(parsed.authority, original.authority, "round-trip authority");
|
||||
CheckEqual(parsed.body, original.body, "round-trip body");
|
||||
CheckEqual(parsed.headers.at("content-type"), original.headers.at("content-type"),
|
||||
"round-trip header");
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
RequestRoundTrip();
|
||||
HeaderNamesAreCaseInsensitive();
|
||||
ChunkedBodyWithTrailers();
|
||||
PipelinedRequests();
|
||||
ExpectContinue();
|
||||
ConnectionCloseAndHTTP10();
|
||||
ResponseFramings();
|
||||
AbsoluteFormTarget();
|
||||
RejectsMalformedMessages();
|
||||
RespectsLimits();
|
||||
Serialisation();
|
||||
SerializeThenParse();
|
||||
|
||||
if (failures != 0) {
|
||||
std::println("{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
87
tests/ShouldSendRecieveHTTP1/main.cpp
Normal file
87
tests/ShouldSendRecieveHTTP1/main.cpp
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// Canonical HTTP/1.1 client/server round-trip: the same shape as
|
||||
// ShouldSendRecieveHTTP, over TCP instead of QUIC.
|
||||
|
||||
import Crafter.Network;
|
||||
import std;
|
||||
using namespace Crafter;
|
||||
|
||||
namespace {
|
||||
int failures = 0;
|
||||
|
||||
void Check(bool condition, std::string_view what) {
|
||||
if (!condition) {
|
||||
std::println("FAIL: {}", what);
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
|
||||
routes["/"] = [](const HTTPRequest&) {
|
||||
return CreateResponseHTTP("200", "Hello World!");
|
||||
};
|
||||
routes["/echo"] = [](const HTTPRequest& request) {
|
||||
return CreateResponseHTTP("200", {{"content-type", "text/plain"}}, request.body);
|
||||
};
|
||||
routes["/query"] = [](const HTTPRequest& request) {
|
||||
return CreateResponseHTTP("200", request.path);
|
||||
};
|
||||
routes["/boom"] = [](const HTTPRequest&) -> HTTPResponse {
|
||||
throw std::runtime_error("handler exploded");
|
||||
};
|
||||
|
||||
try {
|
||||
ListenerAsyncHTTP1 listener(8090, std::move(routes));
|
||||
ClientHTTP1 client("localhost", 8090);
|
||||
|
||||
HTTPResponse hello = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
||||
Check(hello.status == "200", "GET / status");
|
||||
Check(hello.body == "Hello World!", "GET / body");
|
||||
Check(hello.headers.contains("date"), "the server stamps a Date header");
|
||||
Check(hello.headers.at("content-length") == "12", "content-length matches the body");
|
||||
|
||||
HTTPResponse echoed = client.Send(CreateRequestHTTP("POST", "/echo", "localhost",
|
||||
std::string("ping pong")));
|
||||
Check(echoed.status == "200", "POST /echo status");
|
||||
Check(echoed.body == "ping pong", "POST /echo returns the request body");
|
||||
Check(echoed.headers.at("content-type") == "text/plain", "handler headers survive");
|
||||
|
||||
// Query strings must reach the handler intact while still routing on
|
||||
// the bare path — browsers append them to everything.
|
||||
HTTPResponse query = client.Send(CreateRequestHTTP("GET", "/query?a=1&b=2", "localhost"));
|
||||
Check(query.status == "200", "query-string request routes to the bare path");
|
||||
Check(query.body == "/query?a=1&b=2", "the handler sees the full target");
|
||||
|
||||
HTTPResponse missing = client.Send(CreateRequestHTTP("GET", "/nope", "localhost"));
|
||||
Check(missing.status == "404", "unknown route is a 404");
|
||||
|
||||
// A HEAD gets the headers a GET would produce, and no body.
|
||||
HTTPResponse head = client.Send(CreateRequestHTTP("HEAD", "/", "localhost"));
|
||||
Check(head.status == "200", "HEAD status");
|
||||
Check(head.body.empty(), "HEAD has no body");
|
||||
Check(head.headers.at("content-length") == "12", "HEAD still advertises the length");
|
||||
|
||||
// A throwing handler must become a 500, not a dropped connection.
|
||||
HTTPResponse boom = client.Send(CreateRequestHTTP("GET", "/boom", "localhost"));
|
||||
Check(boom.status == "500", "a throwing handler yields 500");
|
||||
Check(boom.body.find("handler exploded") != std::string::npos, "500 carries the reason");
|
||||
|
||||
// Every exchange above shared one connection.
|
||||
Check(listener.listener.AcceptedCount() == 1, "the whole test used a single connection");
|
||||
|
||||
listener.Stop();
|
||||
} catch (const std::exception& error) {
|
||||
std::println("threw: {}", error.what());
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (failures != 0) {
|
||||
std::println("{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
89
tests/ShouldSendRecieveKeepaliveHTTP1/main.cpp
Normal file
89
tests/ShouldSendRecieveKeepaliveHTTP1/main.cpp
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// HTTP/1.1 connection reuse. Unlike HTTP/3 — where multiplexing is the
|
||||
// transport's job — keep-alive here means the same socket carries request
|
||||
// after request, and both ends have to agree on where each message ends.
|
||||
// This test pins that down: repeated requests must not open new
|
||||
// connections, `Connection: close` must end the connection, and the client
|
||||
// must recover when the server closes a pooled connection under it.
|
||||
|
||||
import Crafter.Network;
|
||||
import Crafter.Thread;
|
||||
import std;
|
||||
using namespace Crafter;
|
||||
|
||||
namespace {
|
||||
int failures = 0;
|
||||
|
||||
void Check(bool condition, std::string_view what) {
|
||||
if (!condition) {
|
||||
std::println("FAIL: {}", what);
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
ThreadPool::Start();
|
||||
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
|
||||
routes["/"] = [](const HTTPRequest&) {
|
||||
return CreateResponseHTTP("200", "Hello World!");
|
||||
};
|
||||
routes["/once"] = [](const HTTPRequest&) {
|
||||
// A handler that asks for the connection to end after this reply.
|
||||
return CreateResponseHTTP("200", {{"connection", "close"}}, "bye");
|
||||
};
|
||||
|
||||
try {
|
||||
ListenerAsyncHTTP1 listener(8091, std::move(routes));
|
||||
ClientHTTP1 client("localhost", 8091);
|
||||
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
||||
Check(response.status == "200", "keep-alive request status");
|
||||
Check(response.body == "Hello World!", "keep-alive request body");
|
||||
Check(client.Connected(), "the client keeps the socket between requests");
|
||||
}
|
||||
Check(listener.listener.AcceptedCount() == 1, "five requests shared one connection");
|
||||
|
||||
// A handler that answers with `connection: close` ends the
|
||||
// connection after its response; the client must notice and dial
|
||||
// again for the next request instead of writing into a dead socket.
|
||||
HTTPResponse closing = client.Send(CreateRequestHTTP("GET", "/once", "localhost"));
|
||||
Check(closing.status == "200", "close-flagged response still arrives");
|
||||
Check(closing.body == "bye", "close-flagged response body");
|
||||
Check(!client.Connected(), "the client drops a connection the server closed");
|
||||
|
||||
HTTPResponse after = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
||||
Check(after.body == "Hello World!", "the next request redials transparently");
|
||||
Check(listener.listener.AcceptedCount() == 2, "exactly one extra connection was opened");
|
||||
|
||||
// Force the stale-connection race that HTTP/1.1 keep-alive cannot
|
||||
// avoid: the server goes away while a pooled connection looks
|
||||
// usable. The client is expected to replay the request on a fresh
|
||||
// connection rather than surface the error.
|
||||
Check(client.Connected(), "a connection is pooled before the restart");
|
||||
listener.Stop();
|
||||
ListenerAsyncHTTP1 restarted(8091, {{"/", [](const HTTPRequest&) {
|
||||
return CreateResponseHTTP("200", "Hello Again!");
|
||||
}}});
|
||||
HTTPResponse recovered = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
||||
Check(recovered.body == "Hello Again!", "the client recovers from a stale pooled connection");
|
||||
Check(restarted.listener.AcceptedCount() == 1, "recovery dialled the new listener");
|
||||
|
||||
restarted.Stop();
|
||||
} catch (const std::exception& error) {
|
||||
std::println("threw: {}", error.what());
|
||||
ThreadPool::Stop();
|
||||
return 1;
|
||||
}
|
||||
|
||||
ThreadPool::Stop();
|
||||
if (failures != 0) {
|
||||
std::println("{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
74
tests/ShouldSendRecieveLargeHTTP1/main.cpp
Normal file
74
tests/ShouldSendRecieveLargeHTTP1/main.cpp
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// A body far larger than any socket buffer, in both directions. This is
|
||||
// where short writes, partial reads and off-by-one framing show up: the
|
||||
// request has to survive being split across dozens of send() calls and the
|
||||
// response across dozens of recv() calls.
|
||||
|
||||
import Crafter.Network;
|
||||
import std;
|
||||
using namespace Crafter;
|
||||
|
||||
namespace {
|
||||
int failures = 0;
|
||||
|
||||
void Check(bool condition, std::string_view what) {
|
||||
if (!condition) {
|
||||
std::println("FAIL: {}", what);
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
// Non-uniform payload, so a mangled offset can't accidentally compare
|
||||
// equal the way a run of identical bytes would.
|
||||
std::string MakePayload(std::size_t size) {
|
||||
std::string payload(size, '\0');
|
||||
for (std::size_t i = 0; i < size; ++i) {
|
||||
payload[i] = static_cast<char>('A' + (i * 7 + i / 251) % 26);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
constexpr std::size_t payloadSize = 10 * 1024 * 1024;
|
||||
const std::string payload = MakePayload(payloadSize);
|
||||
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
|
||||
routes["/echo"] = [](const HTTPRequest& request) {
|
||||
return CreateResponseHTTP("200", request.body);
|
||||
};
|
||||
routes["/size"] = [](const HTTPRequest& request) {
|
||||
return CreateResponseHTTP("200", std::to_string(request.body.size()));
|
||||
};
|
||||
|
||||
try {
|
||||
ListenerAsyncHTTP1 listener(8092, std::move(routes));
|
||||
ClientHTTP1 client("localhost", 8092);
|
||||
|
||||
HTTPResponse size = client.Send(CreateRequestHTTP("POST", "/size", "localhost", payload));
|
||||
Check(size.status == "200", "large POST status");
|
||||
Check(size.body == std::to_string(payloadSize), "the server received every byte");
|
||||
|
||||
// Same again in both directions, and on the same connection, so a
|
||||
// leftover byte from the previous exchange would desynchronise the
|
||||
// framing and show up here.
|
||||
HTTPResponse echoed = client.Send(CreateRequestHTTP("POST", "/echo", "localhost", payload));
|
||||
Check(echoed.status == "200", "large echo status");
|
||||
Check(echoed.body.size() == payloadSize, "the response body is the right length");
|
||||
Check(echoed.body == payload, "the response body is byte-for-byte the request");
|
||||
Check(listener.listener.AcceptedCount() == 1, "both transfers shared one connection");
|
||||
|
||||
listener.Stop();
|
||||
} catch (const std::exception& error) {
|
||||
std::println("threw: {}", error.what());
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (failures != 0) {
|
||||
std::println("{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Loading…
Reference in a new issue