README: HTTP/1.1 in the intro, feature list, module list, browser-build exclusions, dependencies and test list, plus a Components section covering both classes, the standalone codec, what is and is not implemented, the smuggling-shaped inputs that are rejected, and an explicit note that this path is plaintext and belongs behind a TLS terminator. ClientHTTP1::timeout was hard-coded and invisible; make it a public member alongside `limits`, mirroring the listener's timeouts. Also pipelining coverage in ShouldSendRecieveHTTP1: two requests written before either is answered, driven from a raw socket since ClientHTTP1 waits for each response. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
115 lines
4.9 KiB
C++
115 lines
4.9 KiB
C++
//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");
|
|
|
|
// Pipelining: two requests written in one go, before either is
|
|
// answered. The responses must come back in order, on that same
|
|
// connection. ClientHTTP1 never does this — it waits for each
|
|
// response — so drive it from a raw socket.
|
|
{
|
|
ClientTCP socket("localhost", 8090);
|
|
const std::string pipelined =
|
|
"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"
|
|
"GET /nope HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
|
socket.Send(pipelined.data(), static_cast<std::uint32_t>(pipelined.size()));
|
|
|
|
HTTP1::MessageParser parser(HTTP1::MessageKind::Response);
|
|
std::string first;
|
|
std::string second;
|
|
while (second.empty()) {
|
|
std::vector<char> chunk = socket.RecieveSync();
|
|
parser.Feed(chunk.data(), chunk.size());
|
|
while (parser.Complete()) {
|
|
HTTPResponse response = parser.TakeResponse();
|
|
(first.empty() ? first : second) = response.status;
|
|
parser.Reset();
|
|
if (!second.empty()) break;
|
|
}
|
|
}
|
|
Check(first == "200", "first pipelined response");
|
|
Check(second == "404", "second pipelined response, in order");
|
|
}
|
|
|
|
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;
|
|
}
|