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
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;
|
||||
}
|
||||
Loading…
Reference in a new issue