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