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
155
implementations/Crafter.Network-ClientHTTP1.cpp
Normal file
155
implementations/Crafter.Network-ClientHTTP1.cpp
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
module;
|
||||||
|
#include <poll.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <cerrno>
|
||||||
|
|
||||||
|
module Crafter.Network:ClientHTTP1_impl;
|
||||||
|
import :ClientHTTP1;
|
||||||
|
import :ClientTCP;
|
||||||
|
import :HTTP;
|
||||||
|
import :HTTP1;
|
||||||
|
import Crafter.Thread;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Read whatever is available, waiting at most `timeout`. Returns 0 on a
|
||||||
|
// clean close by the peer. ClientTCP::RecieveSync() can't express a
|
||||||
|
// timeout — and a server that accepts the connection and then says
|
||||||
|
// nothing would hang Send() forever — so the read is done here.
|
||||||
|
std::size_t ReadSome(int socketid, char* buffer, std::size_t size,
|
||||||
|
std::chrono::milliseconds timeout) {
|
||||||
|
for (;;) {
|
||||||
|
pollfd pfd{ .fd = socketid, .events = POLLIN, .revents = 0 };
|
||||||
|
const int ready = poll(&pfd, 1, static_cast<int>(timeout.count()));
|
||||||
|
if (ready < 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
throw std::runtime_error(std::string("poll failed: ") + std::strerror(errno));
|
||||||
|
}
|
||||||
|
if (ready == 0) throw std::runtime_error("timed out waiting for the response");
|
||||||
|
|
||||||
|
const auto read = recv(socketid, buffer, size, 0);
|
||||||
|
if (read < 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
throw std::runtime_error(std::string("recv failed: ") + std::strerror(errno));
|
||||||
|
}
|
||||||
|
return static_cast<std::size_t>(read);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host header value. The port is elided when it is the scheme default,
|
||||||
|
// which is what every other HTTP/1.1 client on the wire does and what
|
||||||
|
// virtual-host matching on the far side tends to expect.
|
||||||
|
std::string DefaultAuthority(const std::string& host, std::uint16_t port) {
|
||||||
|
if (port == 80) return host;
|
||||||
|
return host + ":" + std::to_string(port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClientHTTP1::Impl {
|
||||||
|
std::string host;
|
||||||
|
std::uint16_t port;
|
||||||
|
std::unique_ptr<ClientTCP> tcp;
|
||||||
|
std::chrono::milliseconds timeout{30000};
|
||||||
|
|
||||||
|
void Connect() {
|
||||||
|
tcp = std::make_unique<ClientTCP>(host, port);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Close() {
|
||||||
|
tcp.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// One request/response exchange on the current connection. `received`
|
||||||
|
// is set as soon as any response byte arrives, which is what decides
|
||||||
|
// whether a failure is safe to replay on a new connection.
|
||||||
|
HTTPResponse Exchange(const std::string& wire, std::string_view method,
|
||||||
|
const HTTP1::MessageLimits& limits, bool& received) {
|
||||||
|
tcp->Send(wire.data(), static_cast<std::uint32_t>(wire.size()));
|
||||||
|
|
||||||
|
HTTP1::MessageParser parser(HTTP1::MessageKind::Response, limits);
|
||||||
|
parser.SetRequestMethod(method);
|
||||||
|
std::vector<char> chunk(16 * 1024);
|
||||||
|
while (!parser.Complete()) {
|
||||||
|
const std::size_t read = ReadSome(tcp->socketid, chunk.data(), chunk.size(), timeout);
|
||||||
|
if (read == 0) {
|
||||||
|
// Peer closed. Completes a response framed by close;
|
||||||
|
// anything else throws out of Finish().
|
||||||
|
parser.Finish();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
received = true;
|
||||||
|
parser.Feed(chunk.data(), read);
|
||||||
|
}
|
||||||
|
if (!parser.Complete()) {
|
||||||
|
throw std::runtime_error("connection closed before a complete response arrived");
|
||||||
|
}
|
||||||
|
HTTPResponse response = parser.TakeResponse();
|
||||||
|
if (!parser.KeepAlive()) Close();
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ClientHTTP1::ClientHTTP1(const char* host, std::uint16_t port)
|
||||||
|
: host(host), port(port), impl(std::make_unique<Impl>(std::string(host), port)) {}
|
||||||
|
|
||||||
|
ClientHTTP1::ClientHTTP1(std::string host, std::uint16_t port)
|
||||||
|
: ClientHTTP1(host.c_str(), port) {}
|
||||||
|
|
||||||
|
ClientHTTP1::ClientHTTP1(ClientHTTP1&&) noexcept = default;
|
||||||
|
ClientHTTP1::~ClientHTTP1() = default;
|
||||||
|
|
||||||
|
bool ClientHTTP1::Connected() const noexcept {
|
||||||
|
return impl && impl->tcp != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ClientHTTP1::Disconnect() {
|
||||||
|
if (impl) impl->Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTPResponse ClientHTTP1::Send(const HTTPRequest& request) {
|
||||||
|
HTTPRequest prepared = request;
|
||||||
|
if (prepared.authority.empty()) prepared.authority = DefaultAuthority(host, port);
|
||||||
|
const std::string wire = HTTP1::SerializeRequest(prepared);
|
||||||
|
const std::string method = prepared.method.empty() ? std::string("GET") : prepared.method;
|
||||||
|
|
||||||
|
// Two attempts at most, and the second one only for the keep-alive
|
||||||
|
// race: the peer may have closed a pooled connection at the same moment
|
||||||
|
// we wrote to it, which is indistinguishable from success until the
|
||||||
|
// read fails. Nothing is replayed once a response byte has arrived.
|
||||||
|
for (int attempt = 0;; ++attempt) {
|
||||||
|
const bool reused = impl->tcp != nullptr;
|
||||||
|
if (!reused) impl->Connect();
|
||||||
|
|
||||||
|
bool received = false;
|
||||||
|
try {
|
||||||
|
return impl->Exchange(wire, method, limits, received);
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
impl->Close();
|
||||||
|
if (attempt == 0 && reused && !received) continue;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ClientHTTP1::SendAsync(const HTTPRequest& request,
|
||||||
|
std::function<void(HTTPResponse)> onSuccess,
|
||||||
|
std::function<void(std::string)> onError) {
|
||||||
|
HTTPRequest copy = request;
|
||||||
|
ThreadPool::Enqueue([this, copy = std::move(copy),
|
||||||
|
onSuccess = std::move(onSuccess),
|
||||||
|
onError = std::move(onError)]() mutable {
|
||||||
|
try {
|
||||||
|
HTTPResponse response = this->Send(copy);
|
||||||
|
if (onSuccess) onSuccess(std::move(response));
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
if (onError) onError(e.what());
|
||||||
|
} catch (...) {
|
||||||
|
if (onError) onError("unknown error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
301
implementations/Crafter.Network-ListenerHTTP1.cpp
Normal file
301
implementations/Crafter.Network-ListenerHTTP1.cpp
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
module;
|
||||||
|
#include <poll.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <cerrno>
|
||||||
|
|
||||||
|
module Crafter.Network:ListenerHTTP1_impl;
|
||||||
|
import :ListenerHTTP1;
|
||||||
|
import :ListenerTCP;
|
||||||
|
import :ClientTCP;
|
||||||
|
import :HTTP;
|
||||||
|
import :HTTP1;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
enum class ReadStatus { Data, Closed, TimedOut };
|
||||||
|
|
||||||
|
// Wait for readable data with a deadline, so an idle or stalled peer
|
||||||
|
// releases the connection thread instead of holding it forever.
|
||||||
|
ReadStatus ReadSome(int socketid, char* buffer, std::size_t size,
|
||||||
|
std::chrono::milliseconds timeout, std::size_t& read) {
|
||||||
|
read = 0;
|
||||||
|
for (;;) {
|
||||||
|
pollfd pfd{ .fd = socketid, .events = POLLIN, .revents = 0 };
|
||||||
|
const int ready = poll(&pfd, 1, static_cast<int>(timeout.count()));
|
||||||
|
if (ready < 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
return ReadStatus::Closed;
|
||||||
|
}
|
||||||
|
if (ready == 0) return ReadStatus::TimedOut;
|
||||||
|
|
||||||
|
const auto got = recv(socketid, buffer, size, 0);
|
||||||
|
if (got < 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
return ReadStatus::Closed;
|
||||||
|
}
|
||||||
|
if (got == 0) return ReadStatus::Closed;
|
||||||
|
read = static_cast<std::size_t>(got);
|
||||||
|
return ReadStatus::Data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything up to '?' — used as the fallback route key so `/thing?x=1`
|
||||||
|
// reaches the handler registered for `/thing`. Browsers append query
|
||||||
|
// strings freely, and requiring handlers to register every variant is
|
||||||
|
// not a workable API.
|
||||||
|
std::string_view PathWithoutQuery(std::string_view target) {
|
||||||
|
return target.substr(0, target.find('?'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One accepted connection: the socket, the thread serving it, and a flag
|
||||||
|
// the accept loop uses to join finished threads without blocking.
|
||||||
|
struct HTTP1Connection {
|
||||||
|
std::unique_ptr<ClientTCP> client;
|
||||||
|
std::thread thread;
|
||||||
|
std::atomic<bool> finished{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ListenerHTTP1::Impl {
|
||||||
|
ListenerHTTP1* owner = nullptr;
|
||||||
|
std::unique_ptr<ListenerTCP> listener;
|
||||||
|
std::mutex mutex;
|
||||||
|
std::vector<std::unique_ptr<HTTP1Connection>> connections;
|
||||||
|
std::atomic<bool> running{true};
|
||||||
|
std::atomic<std::uint64_t> accepted{0};
|
||||||
|
|
||||||
|
// Join threads whose connection has ended. Called from the accept loop
|
||||||
|
// with `mutex` held, so a connection is never reaped mid-registration.
|
||||||
|
void ReapLocked() {
|
||||||
|
std::erase_if(connections, [](const std::unique_ptr<HTTP1Connection>& connection) {
|
||||||
|
if (!connection->finished.load()) return false;
|
||||||
|
if (connection->thread.joinable()) connection->thread.join();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void Adopt(ClientTCP* accepted) {
|
||||||
|
auto connection = std::make_unique<HTTP1Connection>();
|
||||||
|
connection->client.reset(accepted);
|
||||||
|
HTTP1Connection* pointer = connection.get();
|
||||||
|
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
if (!running.load()) return; // shutting down — let the socket close
|
||||||
|
ReapLocked();
|
||||||
|
this->accepted.fetch_add(1);
|
||||||
|
connection->thread = std::thread([this, pointer] {
|
||||||
|
try {
|
||||||
|
Serve(*pointer->client);
|
||||||
|
} catch (...) {
|
||||||
|
// A connection dying must never take the server with it.
|
||||||
|
}
|
||||||
|
pointer->finished.store(true);
|
||||||
|
});
|
||||||
|
connections.push_back(std::move(connection));
|
||||||
|
}
|
||||||
|
|
||||||
|
void Send(ClientTCP& client, const std::string& wire) {
|
||||||
|
client.Send(wire.data(), static_cast<std::uint32_t>(wire.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTPResponse Dispatch(const HTTPRequest& request) {
|
||||||
|
const auto& routes = owner->routes;
|
||||||
|
auto route = routes.find(request.path);
|
||||||
|
if (route == routes.end()) {
|
||||||
|
const std::string bare(PathWithoutQuery(request.path));
|
||||||
|
route = routes.find(bare);
|
||||||
|
}
|
||||||
|
if (route == routes.end()) {
|
||||||
|
return CreateResponseHTTP("404", "Not Found");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return route->second(request);
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
return CreateResponseHTTP("500", std::string(error.what()));
|
||||||
|
} catch (...) {
|
||||||
|
return CreateResponseHTTP("500", "Internal Server Error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve one connection until the peer goes away, asks to close, stalls,
|
||||||
|
// or sends something we refuse to parse.
|
||||||
|
void Serve(ClientTCP& client) {
|
||||||
|
HTTP1::MessageParser parser(HTTP1::MessageKind::Request, owner->limits);
|
||||||
|
std::vector<char> chunk(16 * 1024);
|
||||||
|
bool keepAlive = true;
|
||||||
|
|
||||||
|
while (running.load() && keepAlive) {
|
||||||
|
// ── Read one complete request ─────────────────────────────
|
||||||
|
bool closed = false;
|
||||||
|
try {
|
||||||
|
while (!parser.Complete()) {
|
||||||
|
const bool idle = parser.AtMessageBoundary();
|
||||||
|
const auto timeout = idle ? owner->keepAliveTimeout : owner->requestTimeout;
|
||||||
|
std::size_t read = 0;
|
||||||
|
const ReadStatus status = ReadSome(client.socketid, chunk.data(), chunk.size(),
|
||||||
|
timeout, read);
|
||||||
|
if (status == ReadStatus::TimedOut) {
|
||||||
|
// An idle keep-alive connection is simply dropped;
|
||||||
|
// a half-sent request earns a 408 first.
|
||||||
|
if (!idle) {
|
||||||
|
try {
|
||||||
|
Send(client, HTTP1::SerializeResponse(
|
||||||
|
CreateResponseHTTP("408", "Request Timeout"),
|
||||||
|
{ .keepAlive = false }));
|
||||||
|
} catch (...) {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status == ReadStatus::Closed) {
|
||||||
|
if (parser.AtMessageBoundary()) return; // clean end of connection
|
||||||
|
parser.Finish(); // throws if truncated
|
||||||
|
closed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
parser.Feed(chunk.data(), read);
|
||||||
|
// The peer is holding its body back until we say go.
|
||||||
|
if (parser.ExpectsContinue()) {
|
||||||
|
Send(client, HTTP1::SerializeContinue());
|
||||||
|
parser.ContinueSent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (const HTTP1::HTTP1ProtocolError& error) {
|
||||||
|
try {
|
||||||
|
Send(client, HTTP1::SerializeResponse(
|
||||||
|
CreateResponseHTTP("400", std::string(error.what())),
|
||||||
|
{ .keepAlive = false }));
|
||||||
|
} catch (...) {}
|
||||||
|
return;
|
||||||
|
} catch (...) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parser.Complete()) return;
|
||||||
|
|
||||||
|
// ── Dispatch and answer ───────────────────────────────────
|
||||||
|
const bool http10 = parser.Version() == HTTP1::kVersion10;
|
||||||
|
const bool head = parser.Method() == "HEAD";
|
||||||
|
keepAlive = parser.KeepAlive() && !closed && running.load();
|
||||||
|
|
||||||
|
HTTPRequest request = parser.TakeRequest();
|
||||||
|
HTTPResponse response = Dispatch(request);
|
||||||
|
// A handler may end the connection by answering with
|
||||||
|
// `connection: close`. The header itself is hop-by-hop and gets
|
||||||
|
// re-derived by the serialiser, so this is where it takes effect.
|
||||||
|
if (auto connection = response.headers.find("connection");
|
||||||
|
connection != response.headers.end() && HTTP1::HasToken(connection->second, "close")) {
|
||||||
|
keepAlive = false;
|
||||||
|
}
|
||||||
|
if (!response.headers.contains("date")) {
|
||||||
|
response.headers["date"] = HTTP1::FormatHTTPDate(std::chrono::system_clock::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Send(client, HTTP1::SerializeResponse(response, {
|
||||||
|
.keepAlive = keepAlive,
|
||||||
|
// HTTP/1.0 peers only reuse a connection they were told
|
||||||
|
// stays open.
|
||||||
|
.explicitKeepAlive = http10,
|
||||||
|
.omitBody = head,
|
||||||
|
}));
|
||||||
|
} catch (...) {
|
||||||
|
return; // peer vanished mid-response
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!keepAlive) return;
|
||||||
|
try {
|
||||||
|
// Rearms for the next request, parsing anything the peer
|
||||||
|
// already pipelined behind this one.
|
||||||
|
parser.Reset();
|
||||||
|
} catch (const HTTP1::HTTP1ProtocolError&) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ListenerHTTP1::ListenerHTTP1(std::uint16_t port,
|
||||||
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes)
|
||||||
|
: routes(std::move(routes))
|
||||||
|
, impl(std::make_unique<Impl>())
|
||||||
|
{
|
||||||
|
impl->owner = this;
|
||||||
|
Impl* state = impl.get();
|
||||||
|
impl->listener = std::make_unique<ListenerTCP>(port, [state](ClientTCP* client) {
|
||||||
|
state->Adopt(client);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ListenerHTTP1::ListenerHTTP1(ListenerHTTP1&& other) noexcept
|
||||||
|
: routes(std::move(other.routes))
|
||||||
|
, keepAliveTimeout(other.keepAliveTimeout)
|
||||||
|
, requestTimeout(other.requestTimeout)
|
||||||
|
, limits(other.limits)
|
||||||
|
, impl(std::move(other.impl))
|
||||||
|
{
|
||||||
|
// The accept callback reaches the routes through Impl::owner, so the
|
||||||
|
// back-pointer has to follow the object.
|
||||||
|
if (impl) impl->owner = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
ListenerHTTP1::~ListenerHTTP1() {
|
||||||
|
if (impl) Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ListenerHTTP1::Listen() {
|
||||||
|
if (!impl || !impl->listener) return;
|
||||||
|
// Accept on this thread; each connection is served on its own thread
|
||||||
|
// (see Impl::Adopt), so a slow keep-alive peer can't stall the loop.
|
||||||
|
impl->listener->ListenSyncSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ListenerHTTP1::Stop() {
|
||||||
|
if (!impl) return;
|
||||||
|
if (!impl->running.exchange(false)) return;
|
||||||
|
if (impl->listener) impl->listener->Stop();
|
||||||
|
|
||||||
|
std::vector<std::unique_ptr<HTTP1Connection>> closing;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(impl->mutex);
|
||||||
|
for (auto& connection : impl->connections) {
|
||||||
|
// Wake the serving thread out of poll()/recv() without closing
|
||||||
|
// the descriptor underneath it.
|
||||||
|
if (connection->client) shutdown(connection->client->socketid, SHUT_RDWR);
|
||||||
|
}
|
||||||
|
closing = std::move(impl->connections);
|
||||||
|
impl->connections.clear();
|
||||||
|
}
|
||||||
|
for (auto& connection : closing) {
|
||||||
|
if (connection->thread.joinable()) connection->thread.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t ListenerHTTP1::ConnectionCount() const {
|
||||||
|
if (!impl) return 0;
|
||||||
|
std::lock_guard lock(impl->mutex);
|
||||||
|
return impl->connections.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint64_t ListenerHTTP1::AcceptedCount() const {
|
||||||
|
return impl ? impl->accepted.load() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ListenerAsyncHTTP1::ListenerAsyncHTTP1(std::uint16_t port,
|
||||||
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes)
|
||||||
|
: listener(port, std::move(routes))
|
||||||
|
, thread(&ListenerHTTP1::Listen, &listener)
|
||||||
|
{}
|
||||||
|
|
||||||
|
ListenerAsyncHTTP1::~ListenerAsyncHTTP1() {
|
||||||
|
Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ListenerAsyncHTTP1::Stop() {
|
||||||
|
listener.Stop();
|
||||||
|
if (thread.joinable()) thread.join();
|
||||||
|
}
|
||||||
69
interfaces/Crafter.Network-ClientHTTP1.cppm
Normal file
69
interfaces/Crafter.Network-ClientHTTP1.cppm
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
export module Crafter.Network:ClientHTTP1;
|
||||||
|
import std;
|
||||||
|
import :HTTP;
|
||||||
|
import :HTTP1;
|
||||||
|
|
||||||
|
#ifndef CRAFTER_NETWORK_BROWSER
|
||||||
|
namespace Crafter {
|
||||||
|
// HTTP/1.1 client over plain TCP, for peers that cannot speak HTTP/3.
|
||||||
|
// The request/response types are the ones the HTTP/3 client uses, so
|
||||||
|
// swapping ClientHTTP for ClientHTTP1 is a one-line change at the call
|
||||||
|
// site.
|
||||||
|
//
|
||||||
|
// The connection is persistent: the first Send() dials, and later calls
|
||||||
|
// reuse the socket unless the peer asked for it to be closed
|
||||||
|
// (`Connection: close`, or an HTTP/1.0 response without
|
||||||
|
// `Connection: keep-alive`). A reused connection that turns out to have
|
||||||
|
// been closed by the peer in the meantime — the unavoidable race in
|
||||||
|
// HTTP/1.1 keep-alive — is redialled once and the request replayed;
|
||||||
|
// a freshly dialled connection is never replayed on, so a genuinely
|
||||||
|
// broken server surfaces as an exception rather than a retry loop.
|
||||||
|
//
|
||||||
|
// Thread-affinity matches ClientHTTP: one ClientHTTP1 serves one caller
|
||||||
|
// at a time; distinct instances are independent.
|
||||||
|
//
|
||||||
|
// No TLS. This talks `http://`; for an encrypted transport use
|
||||||
|
// ClientHTTP (HTTP/3 over QUIC), or put a TLS-terminating proxy in
|
||||||
|
// front of the HTTP/1.1 endpoint.
|
||||||
|
export class ClientHTTP1 {
|
||||||
|
public:
|
||||||
|
std::string host;
|
||||||
|
std::uint16_t port;
|
||||||
|
|
||||||
|
ClientHTTP1(const char* host, std::uint16_t port);
|
||||||
|
ClientHTTP1(std::string host, std::uint16_t port);
|
||||||
|
|
||||||
|
~ClientHTTP1();
|
||||||
|
ClientHTTP1(const ClientHTTP1&) = delete;
|
||||||
|
ClientHTTP1(ClientHTTP1&&) noexcept;
|
||||||
|
|
||||||
|
// Send a request and read the full response. `authority` defaults to
|
||||||
|
// the host:port this client was constructed with; `scheme` is
|
||||||
|
// ignored (the transport is plaintext).
|
||||||
|
HTTPResponse Send(const HTTPRequest& request);
|
||||||
|
|
||||||
|
// Send a request and deliver the response (or the error text) via
|
||||||
|
// callback, on Crafter.Thread's ThreadPool.
|
||||||
|
void SendAsync(const HTTPRequest& request,
|
||||||
|
std::function<void(HTTPResponse)> onSuccess,
|
||||||
|
std::function<void(std::string)> onError);
|
||||||
|
|
||||||
|
// Whether a pooled connection is currently open. Mostly useful for
|
||||||
|
// tests asserting that keep-alive actually kept the socket.
|
||||||
|
bool Connected() const noexcept;
|
||||||
|
|
||||||
|
// Drop the pooled connection; the next Send() dials again.
|
||||||
|
void Disconnect();
|
||||||
|
|
||||||
|
// Limits applied to responses. Set before the first Send().
|
||||||
|
HTTP1::MessageLimits limits;
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Impl;
|
||||||
|
std::unique_ptr<Impl> impl;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
#endif
|
||||||
777
interfaces/Crafter.Network-HTTP1.cppm
Normal file
777
interfaces/Crafter.Network-HTTP1.cppm
Normal file
|
|
@ -0,0 +1,777 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// HTTP/1.1 wire format — RFC 9112 syntax on top of the RFC 9110 semantics
|
||||||
|
// already modelled by :HTTP (HTTPRequest / HTTPResponse are shared with the
|
||||||
|
// HTTP/3 stack, so a route handler written for one works unchanged on the
|
||||||
|
// other).
|
||||||
|
//
|
||||||
|
// Scope:
|
||||||
|
// - request / response serialisation, with the framing headers
|
||||||
|
// (host, content-length, transfer-encoding, connection) owned by the
|
||||||
|
// serialiser rather than the caller
|
||||||
|
// - an incremental parser that consumes arbitrary socket chunks and
|
||||||
|
// surfaces one message at a time, so a single connection can carry a
|
||||||
|
// keep-alive series (and pipelined requests)
|
||||||
|
// - both body framings that matter in practice: content-length and
|
||||||
|
// `Transfer-Encoding: chunked` (plus read-to-EOF for responses that
|
||||||
|
// use neither, RFC 9112 §6.3 item 8)
|
||||||
|
//
|
||||||
|
// Deliberately rejected rather than guessed at, because getting these
|
||||||
|
// wrong is how request smuggling happens (RFC 9112 §11.2):
|
||||||
|
// - Content-Length together with Transfer-Encoding
|
||||||
|
// - conflicting duplicate Content-Length values
|
||||||
|
// - whitespace between a field name and its colon
|
||||||
|
// - CR/LF inside a field value we are asked to serialise
|
||||||
|
//
|
||||||
|
// This partition has no transport dependency; ClientHTTP1 and ListenerHTTP1
|
||||||
|
// pair it with :ClientTCP / :ListenerTCP.
|
||||||
|
|
||||||
|
export module Crafter.Network:HTTP1;
|
||||||
|
import std;
|
||||||
|
import :HTTP;
|
||||||
|
|
||||||
|
namespace Crafter::HTTP1 {
|
||||||
|
// ---------------- Versions ----------------
|
||||||
|
export inline constexpr std::string_view kVersion10 = "HTTP/1.0";
|
||||||
|
export inline constexpr std::string_view kVersion11 = "HTTP/1.1";
|
||||||
|
|
||||||
|
// ---------------- Errors ----------------
|
||||||
|
// Thrown for any input we refuse to interpret. Callers turn this into a
|
||||||
|
// 400 (server side) or propagate it out of Send() (client side).
|
||||||
|
export class HTTP1ProtocolError : public std::runtime_error {
|
||||||
|
public:
|
||||||
|
using std::runtime_error::runtime_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------- Lexical helpers (RFC 9110 §5.6) ----------------
|
||||||
|
inline bool IsTChar(unsigned char c) {
|
||||||
|
constexpr std::string_view extra = "!#$%&'*+-.^_`|~";
|
||||||
|
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||||
|
|| extra.find(static_cast<char>(c)) != std::string_view::npos;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool IsToken(std::string_view s) {
|
||||||
|
return !s.empty() && std::ranges::all_of(s, [](char c) {
|
||||||
|
return IsTChar(static_cast<unsigned char>(c));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
inline std::string ToLowerAscii(std::string_view s) {
|
||||||
|
std::string out(s);
|
||||||
|
std::ranges::transform(out, out.begin(), [](unsigned char c) {
|
||||||
|
return static_cast<char>(c >= 'A' && c <= 'Z' ? c + ('a' - 'A') : c);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool EqualsIgnoreCase(std::string_view a, std::string_view b) {
|
||||||
|
return std::ranges::equal(a, b, [](unsigned char x, unsigned char y) {
|
||||||
|
auto lower = [](unsigned char c) { return c >= 'A' && c <= 'Z' ? c + ('a' - 'A') : c; };
|
||||||
|
return lower(x) == lower(y);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional whitespace — SP / HTAB only (RFC 9110 §5.6.3).
|
||||||
|
inline std::string_view TrimOWS(std::string_view s) {
|
||||||
|
while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) s.remove_prefix(1);
|
||||||
|
while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.remove_suffix(1);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split a comma-separated field value into trimmed tokens (#rule).
|
||||||
|
export inline std::vector<std::string_view> SplitCommaList(std::string_view value) {
|
||||||
|
std::vector<std::string_view> parts;
|
||||||
|
while (!value.empty()) {
|
||||||
|
auto comma = value.find(',');
|
||||||
|
parts.push_back(TrimOWS(value.substr(0, comma)));
|
||||||
|
if (comma == std::string_view::npos) break;
|
||||||
|
value.remove_prefix(comma + 1);
|
||||||
|
}
|
||||||
|
std::erase_if(parts, [](std::string_view p) { return p.empty(); });
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether a comma-separated field value contains `token`, compared
|
||||||
|
// case-insensitively — the shape of `Connection`, `Expect`,
|
||||||
|
// `Transfer-Encoding` and friends.
|
||||||
|
export inline bool HasToken(std::string_view fieldValue, std::string_view token) {
|
||||||
|
return std::ranges::any_of(SplitCommaList(fieldValue), [token](std::string_view candidate) {
|
||||||
|
return EqualsIgnoreCase(candidate, token);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Status reason phrases ----------------
|
||||||
|
// HTTP/1.1 keeps a reason phrase in the status line. It carries no
|
||||||
|
// meaning (RFC 9112 §4) but some ancient clients log it, and an empty
|
||||||
|
// one trips up a few proxies, so emit the registered text where we know
|
||||||
|
// it and a generic class name otherwise.
|
||||||
|
export inline std::string_view ReasonPhrase(std::string_view status) {
|
||||||
|
static const std::unordered_map<std::string_view, std::string_view> known = {
|
||||||
|
{"100", "Continue"}, {"101", "Switching Protocols"}, {"103", "Early Hints"},
|
||||||
|
{"200", "OK"}, {"201", "Created"}, {"202", "Accepted"}, {"204", "No Content"},
|
||||||
|
{"206", "Partial Content"},
|
||||||
|
{"301", "Moved Permanently"}, {"302", "Found"}, {"303", "See Other"},
|
||||||
|
{"304", "Not Modified"}, {"307", "Temporary Redirect"}, {"308", "Permanent Redirect"},
|
||||||
|
{"400", "Bad Request"}, {"401", "Unauthorized"}, {"403", "Forbidden"},
|
||||||
|
{"404", "Not Found"}, {"405", "Method Not Allowed"}, {"408", "Request Timeout"},
|
||||||
|
{"409", "Conflict"}, {"411", "Length Required"}, {"413", "Content Too Large"},
|
||||||
|
{"414", "URI Too Long"}, {"415", "Unsupported Media Type"},
|
||||||
|
{"426", "Upgrade Required"}, {"429", "Too Many Requests"},
|
||||||
|
{"431", "Request Header Fields Too Large"},
|
||||||
|
{"500", "Internal Server Error"}, {"501", "Not Implemented"},
|
||||||
|
{"502", "Bad Gateway"}, {"503", "Service Unavailable"}, {"504", "Gateway Timeout"},
|
||||||
|
{"505", "HTTP Version Not Supported"},
|
||||||
|
};
|
||||||
|
if (auto it = known.find(status); it != known.end()) return it->second;
|
||||||
|
if (status.size() == 3) {
|
||||||
|
switch (status[0]) {
|
||||||
|
case '1': return "Informational";
|
||||||
|
case '2': return "Success";
|
||||||
|
case '3': return "Redirection";
|
||||||
|
case '4': return "Client Error";
|
||||||
|
case '5': return "Server Error";
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMF-fixdate, the only date format an HTTP/1.1 sender may generate
|
||||||
|
// (RFC 9110 §5.6.7). Always UTC.
|
||||||
|
export inline std::string FormatHTTPDate(std::chrono::system_clock::time_point when) {
|
||||||
|
return std::format("{:%a, %d %b %Y %H:%M:%S} GMT",
|
||||||
|
std::chrono::floor<std::chrono::seconds>(when));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Serialisation ----------------
|
||||||
|
export struct RequestOptions {
|
||||||
|
// false emits `connection: close`, telling the peer we will not
|
||||||
|
// reuse the connection after this exchange.
|
||||||
|
bool keepAlive = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export struct ResponseOptions {
|
||||||
|
bool keepAlive = true;
|
||||||
|
// HTTP/1.0 peers have no keep-alive by default, so reuse has to be
|
||||||
|
// stated explicitly for them (RFC 9112 §9.3).
|
||||||
|
bool explicitKeepAlive = false;
|
||||||
|
// Response to HEAD: send the header section, including the
|
||||||
|
// content-length the body *would* have had, but no body.
|
||||||
|
bool omitBody = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Headers whose value is derived from the message rather than copied
|
||||||
|
// from the caller's map — emitting the caller's copy as well would
|
||||||
|
// produce a duplicate, and for content-length a smuggling vector.
|
||||||
|
inline bool IsFramingHeader(std::string_view lowerName) {
|
||||||
|
return lowerName == "host" || lowerName == "content-length"
|
||||||
|
|| lowerName == "transfer-encoding" || lowerName == "connection";
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void ValidateFieldName(std::string_view name) {
|
||||||
|
if (!IsToken(name)) {
|
||||||
|
throw HTTP1ProtocolError("invalid header field name: '" + std::string(name) + "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A CR or LF smuggled through a field value would split the message.
|
||||||
|
inline void ValidateFieldValue(std::string_view name, std::string_view value) {
|
||||||
|
if (value.find_first_of("\r\n") != std::string_view::npos) {
|
||||||
|
throw HTTP1ProtocolError("header '" + std::string(name) + "' contains CR/LF");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void AppendHeaders(std::string& out,
|
||||||
|
const std::unordered_map<std::string, std::string>& headers) {
|
||||||
|
for (const auto& [name, value] : headers) {
|
||||||
|
// HTTP/3 pseudo-headers have no HTTP/1.1 equivalent — they are
|
||||||
|
// already represented by the start line.
|
||||||
|
if (name.empty() || name.front() == ':') continue;
|
||||||
|
std::string lower = ToLowerAscii(name);
|
||||||
|
if (IsFramingHeader(lower)) continue;
|
||||||
|
ValidateFieldName(lower);
|
||||||
|
ValidateFieldValue(lower, value);
|
||||||
|
out += lower;
|
||||||
|
out += ": ";
|
||||||
|
out += value;
|
||||||
|
out += "\r\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialise a request in origin-form. `authority` becomes the mandatory
|
||||||
|
// Host header (RFC 9112 §3.2); ClientHTTP1 fills it in from the
|
||||||
|
// connection when the caller left it empty.
|
||||||
|
export inline std::string SerializeRequest(const HTTPRequest& request, RequestOptions options = {}) {
|
||||||
|
const std::string method = request.method.empty() ? std::string("GET") : request.method;
|
||||||
|
const std::string target = request.path.empty() ? std::string("/") : request.path;
|
||||||
|
if (!IsToken(method)) throw HTTP1ProtocolError("invalid request method: '" + method + "'");
|
||||||
|
if (target.find_first_of(" \r\n") != std::string::npos) {
|
||||||
|
throw HTTP1ProtocolError("invalid request target: '" + target + "'");
|
||||||
|
}
|
||||||
|
ValidateFieldValue("host", request.authority);
|
||||||
|
|
||||||
|
std::string out;
|
||||||
|
out.reserve(256 + request.headers.size() * 32 + request.body.size());
|
||||||
|
out += method;
|
||||||
|
out += ' ';
|
||||||
|
out += target;
|
||||||
|
out += " HTTP/1.1\r\n";
|
||||||
|
out += "host: ";
|
||||||
|
out += request.authority;
|
||||||
|
out += "\r\n";
|
||||||
|
AppendHeaders(out, request.headers);
|
||||||
|
// A body always gets an explicit length; so does a method that is
|
||||||
|
// normally expected to carry one, since some servers answer 411 for
|
||||||
|
// a bodyless POST that omits it.
|
||||||
|
if (!request.body.empty() || method == "POST" || method == "PUT" || method == "PATCH") {
|
||||||
|
out += "content-length: ";
|
||||||
|
out += std::to_string(request.body.size());
|
||||||
|
out += "\r\n";
|
||||||
|
}
|
||||||
|
if (!options.keepAlive) out += "connection: close\r\n";
|
||||||
|
out += "\r\n";
|
||||||
|
out += request.body;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export inline std::string SerializeResponse(const HTTPResponse& response, ResponseOptions options = {}) {
|
||||||
|
std::string status = response.status.empty() ? std::string("200") : response.status;
|
||||||
|
if (status.size() != 3 || !std::ranges::all_of(status, [](char c) { return c >= '0' && c <= '9'; })) {
|
||||||
|
throw HTTP1ProtocolError("status must be three digits, got '" + status + "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string out;
|
||||||
|
out.reserve(256 + response.headers.size() * 32 + response.body.size());
|
||||||
|
out += "HTTP/1.1 ";
|
||||||
|
out += status;
|
||||||
|
out += ' ';
|
||||||
|
out += ReasonPhrase(status);
|
||||||
|
out += "\r\n";
|
||||||
|
AppendHeaders(out, response.headers);
|
||||||
|
// 1xx / 204 / 304 must not carry a content-length at all — sending
|
||||||
|
// one makes some clients wait for a body that never arrives.
|
||||||
|
const bool bodyless = status[0] == '1' || status == "204" || status == "304";
|
||||||
|
if (!bodyless) {
|
||||||
|
out += "content-length: ";
|
||||||
|
out += std::to_string(response.body.size());
|
||||||
|
out += "\r\n";
|
||||||
|
}
|
||||||
|
if (!options.keepAlive) out += "connection: close\r\n";
|
||||||
|
else if (options.explicitKeepAlive) out += "connection: keep-alive\r\n";
|
||||||
|
out += "\r\n";
|
||||||
|
if (!options.omitBody && !bodyless) out += response.body;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The interim response a server owes a client that sent
|
||||||
|
// `Expect: 100-continue` before it will send its body (RFC 9110 §10.1.1).
|
||||||
|
export inline std::string SerializeContinue() {
|
||||||
|
return "HTTP/1.1 100 Continue\r\n\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Incremental parser ----------------
|
||||||
|
export enum class MessageKind { Request, Response };
|
||||||
|
|
||||||
|
// Bounds on what we are willing to buffer before declaring the peer
|
||||||
|
// hostile. maxBody applies to the decoded body, so it also caps a
|
||||||
|
// chunked stream that never ends.
|
||||||
|
export struct MessageLimits {
|
||||||
|
std::size_t maxStartLine = 8 * 1024;
|
||||||
|
std::size_t maxHeaderSection = 64 * 1024;
|
||||||
|
std::uint64_t maxBody = 512ull * 1024 * 1024;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Feed socket bytes in with Feed(); call Finish() when the peer closes
|
||||||
|
// its send side. Complete() then reports whether a whole message is
|
||||||
|
// available, and Take*() moves it out. Reset() rearms for the next
|
||||||
|
// message on the same connection, keeping any bytes that already
|
||||||
|
// belonged to it (HTTP/1.1 pipelining).
|
||||||
|
export class MessageParser {
|
||||||
|
public:
|
||||||
|
explicit MessageParser(MessageKind kind, MessageLimits limits = {})
|
||||||
|
: kind(kind), limits(limits) {}
|
||||||
|
|
||||||
|
// Responses are framed partly by the request that provoked them: a
|
||||||
|
// HEAD response has no body no matter what its content-length says.
|
||||||
|
// Tell the parser before feeding it bytes.
|
||||||
|
void SetRequestMethod(std::string_view requestMethod) {
|
||||||
|
headRequest = EqualsIgnoreCase(requestMethod, "HEAD");
|
||||||
|
}
|
||||||
|
|
||||||
|
void Feed(const char* data, std::size_t size) {
|
||||||
|
if (size != 0) buffer.append(data, size);
|
||||||
|
Advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Feed(std::span<const char> bytes) { Feed(bytes.data(), bytes.size()); }
|
||||||
|
|
||||||
|
// The peer closed. Completes a read-to-EOF body; anything else
|
||||||
|
// that is mid-message is a truncation error.
|
||||||
|
void Finish() {
|
||||||
|
eof = true;
|
||||||
|
Advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HeadersComplete() const noexcept { return headersDone; }
|
||||||
|
bool Complete() const noexcept { return state == State::Done; }
|
||||||
|
|
||||||
|
// True while the peer is waiting for `100 Continue` before it sends
|
||||||
|
// the body. Clear it with ContinueSent() once the interim response
|
||||||
|
// is on the wire.
|
||||||
|
bool ExpectsContinue() const noexcept { return expectContinue && !Complete(); }
|
||||||
|
void ContinueSent() noexcept { expectContinue = false; }
|
||||||
|
|
||||||
|
// Whether the connection may carry another message after this one.
|
||||||
|
// Meaningful once HeadersComplete().
|
||||||
|
bool KeepAlive() const noexcept { return keepAlive; }
|
||||||
|
|
||||||
|
// No partial message buffered — a clean point to stop reading.
|
||||||
|
bool AtMessageBoundary() const noexcept {
|
||||||
|
return state == State::StartLine && pos == buffer.size() && !headersDone;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string_view Method() const noexcept { return method; }
|
||||||
|
std::string_view Version() const noexcept { return version; }
|
||||||
|
std::string_view Status() const noexcept { return status; }
|
||||||
|
|
||||||
|
HTTPRequest TakeRequest() {
|
||||||
|
if (state != State::Done) throw HTTP1ProtocolError("request is not complete");
|
||||||
|
HTTPRequest request;
|
||||||
|
request.method = std::move(method);
|
||||||
|
request.path = std::move(path);
|
||||||
|
request.scheme = scheme.empty() ? std::string("http") : std::move(scheme);
|
||||||
|
// Absolute-form targets carry their own authority and win over
|
||||||
|
// Host (RFC 9112 §3.2.2); otherwise Host supplies it. Either way
|
||||||
|
// it lives in the named field, matching the HTTP/3 shape, so it
|
||||||
|
// is removed from the header map.
|
||||||
|
if (authority.empty()) {
|
||||||
|
if (auto it = headers.find("host"); it != headers.end()) authority = it->second;
|
||||||
|
}
|
||||||
|
headers.erase("host");
|
||||||
|
request.authority = std::move(authority);
|
||||||
|
request.headers = std::move(headers);
|
||||||
|
request.body = std::move(body);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTPResponse TakeResponse() {
|
||||||
|
if (state != State::Done) throw HTTP1ProtocolError("response is not complete");
|
||||||
|
HTTPResponse response;
|
||||||
|
response.status = std::move(status);
|
||||||
|
response.headers = std::move(headers);
|
||||||
|
response.body = std::move(body);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rearm for the next message. Any bytes past the one just taken are
|
||||||
|
// retained and parsed immediately, so a pipelined request can be
|
||||||
|
// Complete() as soon as this returns.
|
||||||
|
void Reset() {
|
||||||
|
buffer.erase(0, pos);
|
||||||
|
pos = 0;
|
||||||
|
state = State::StartLine;
|
||||||
|
method.clear();
|
||||||
|
path.clear();
|
||||||
|
scheme.clear();
|
||||||
|
authority.clear();
|
||||||
|
version.clear();
|
||||||
|
status.clear();
|
||||||
|
lastHeaderName.clear();
|
||||||
|
headers.clear();
|
||||||
|
body.clear();
|
||||||
|
headerBytes = 0;
|
||||||
|
bodyRemaining = 0;
|
||||||
|
chunkRemaining = 0;
|
||||||
|
bodyMode = BodyMode::None;
|
||||||
|
headersDone = false;
|
||||||
|
expectContinue = false;
|
||||||
|
keepAlive = true;
|
||||||
|
headRequest = false;
|
||||||
|
Advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum class State { StartLine, Headers, Body, ChunkSize, ChunkData, ChunkTrailingCRLF, Trailers, Done };
|
||||||
|
enum class BodyMode { None, Length, Chunked, UntilClose };
|
||||||
|
|
||||||
|
// Read one CRLF-terminated line. A bare LF is accepted as well: it
|
||||||
|
// is not legal to generate, but recipients may recognise it
|
||||||
|
// (RFC 9112 §2.2) and some embedded clients still emit it.
|
||||||
|
std::optional<std::string_view> TryReadLine(std::size_t maxLength) {
|
||||||
|
const std::size_t nl = buffer.find('\n', pos);
|
||||||
|
if (nl == std::string::npos) {
|
||||||
|
if (buffer.size() - pos > maxLength) {
|
||||||
|
throw HTTP1ProtocolError("line exceeds " + std::to_string(maxLength) + " bytes");
|
||||||
|
}
|
||||||
|
if (eof && buffer.size() != pos) {
|
||||||
|
throw HTTP1ProtocolError("connection closed mid-message");
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
std::size_t end = nl;
|
||||||
|
if (end > pos && buffer[end - 1] == '\r') --end;
|
||||||
|
if (end - pos > maxLength) {
|
||||||
|
throw HTTP1ProtocolError("line exceeds " + std::to_string(maxLength) + " bytes");
|
||||||
|
}
|
||||||
|
std::string_view line(buffer.data() + pos, end - pos);
|
||||||
|
pos = nl + 1;
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ParseRequestLine(std::string_view line) {
|
||||||
|
const auto first = line.find(' ');
|
||||||
|
if (first == std::string_view::npos) throw HTTP1ProtocolError("malformed request line");
|
||||||
|
const auto second = line.find(' ', first + 1);
|
||||||
|
if (second == std::string_view::npos) {
|
||||||
|
// HTTP/0.9 simple-request. Not supported, and accepting it
|
||||||
|
// would leave us unable to answer with headers.
|
||||||
|
throw HTTP1ProtocolError("malformed request line (missing version)");
|
||||||
|
}
|
||||||
|
method = std::string(line.substr(0, first));
|
||||||
|
const std::string_view target = line.substr(first + 1, second - first - 1);
|
||||||
|
version = std::string(line.substr(second + 1));
|
||||||
|
if (!IsToken(method)) throw HTTP1ProtocolError("invalid method token");
|
||||||
|
if (target.empty()) throw HTTP1ProtocolError("empty request target");
|
||||||
|
CheckVersion();
|
||||||
|
|
||||||
|
if (target.front() == '/') {
|
||||||
|
path = std::string(target); // origin-form
|
||||||
|
} else if (target == "*") {
|
||||||
|
path = "*"; // asterisk-form (OPTIONS)
|
||||||
|
} else if (const auto sep = target.find("://"); sep != std::string_view::npos) {
|
||||||
|
// absolute-form, as sent to a proxy.
|
||||||
|
scheme = ToLowerAscii(target.substr(0, sep));
|
||||||
|
const std::string_view rest = target.substr(sep + 3);
|
||||||
|
const auto slash = rest.find('/');
|
||||||
|
authority = std::string(rest.substr(0, slash));
|
||||||
|
path = slash == std::string_view::npos ? std::string("/") : std::string(rest.substr(slash));
|
||||||
|
} else {
|
||||||
|
// authority-form — only legal for CONNECT, which this
|
||||||
|
// listener does not implement.
|
||||||
|
authority = std::string(target);
|
||||||
|
path = std::string(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ParseStatusLine(std::string_view line) {
|
||||||
|
const auto first = line.find(' ');
|
||||||
|
if (first == std::string_view::npos) throw HTTP1ProtocolError("malformed status line");
|
||||||
|
version = std::string(line.substr(0, first));
|
||||||
|
CheckVersion();
|
||||||
|
std::string_view rest = TrimOWS(line.substr(first + 1));
|
||||||
|
const auto space = rest.find(' ');
|
||||||
|
status = std::string(space == std::string_view::npos ? rest : rest.substr(0, space));
|
||||||
|
if (status.size() != 3
|
||||||
|
|| !std::ranges::all_of(status, [](char c) { return c >= '0' && c <= '9'; })) {
|
||||||
|
throw HTTP1ProtocolError("malformed status code '" + status + "'");
|
||||||
|
}
|
||||||
|
// The reason phrase is discarded: it carries no semantics and
|
||||||
|
// HTTPResponse has nowhere to put it.
|
||||||
|
}
|
||||||
|
|
||||||
|
void CheckVersion() {
|
||||||
|
if (version != kVersion11 && version != kVersion10) {
|
||||||
|
throw HTTP1ProtocolError("unsupported HTTP version '" + version + "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AddHeader(std::string name, std::string_view value) {
|
||||||
|
auto [it, inserted] = headers.try_emplace(std::move(name), std::string(value));
|
||||||
|
if (!inserted) {
|
||||||
|
// Repeated field lines are equivalent to one comma-joined
|
||||||
|
// value (RFC 9110 §5.3) — except content-length, where
|
||||||
|
// disagreeing values are a smuggling attempt.
|
||||||
|
if (it->first == "content-length" && it->second != value) {
|
||||||
|
throw HTTP1ProtocolError("conflicting content-length values");
|
||||||
|
}
|
||||||
|
if (it->first != "content-length") {
|
||||||
|
it->second += ", ";
|
||||||
|
it->second += value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ParseHeaderLine(std::string_view line) {
|
||||||
|
headerBytes += line.size() + 2;
|
||||||
|
if (headerBytes > limits.maxHeaderSection) {
|
||||||
|
throw HTTP1ProtocolError("header section exceeds "
|
||||||
|
+ std::to_string(limits.maxHeaderSection) + " bytes");
|
||||||
|
}
|
||||||
|
if (line.front() == ' ' || line.front() == '\t') {
|
||||||
|
// obs-fold. RFC 9112 §5.2 allows replacing it with a space
|
||||||
|
// rather than rejecting the message.
|
||||||
|
if (lastHeaderName.empty()) throw HTTP1ProtocolError("obs-fold before any header");
|
||||||
|
auto it = headers.find(lastHeaderName);
|
||||||
|
if (it != headers.end()) {
|
||||||
|
it->second += ' ';
|
||||||
|
it->second += TrimOWS(line);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto colon = line.find(':');
|
||||||
|
if (colon == std::string_view::npos) throw HTTP1ProtocolError("header line has no colon");
|
||||||
|
const std::string_view rawName = line.substr(0, colon);
|
||||||
|
// "No whitespace is allowed between the field name and colon" —
|
||||||
|
// a recipient MUST reject such a message (RFC 9112 §5.1).
|
||||||
|
if (!IsToken(rawName)) throw HTTP1ProtocolError("invalid header field name");
|
||||||
|
lastHeaderName = ToLowerAscii(rawName);
|
||||||
|
AddHeader(lastHeaderName, TrimOWS(line.substr(colon + 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::string_view> Header(std::string_view name) const {
|
||||||
|
auto it = headers.find(std::string(name));
|
||||||
|
if (it == headers.end()) return std::nullopt;
|
||||||
|
return std::string_view(it->second);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ResolveKeepAlive() {
|
||||||
|
const auto connection = Header("connection");
|
||||||
|
const bool close = connection && HasToken(*connection, "close");
|
||||||
|
const bool explicitKeep = connection && HasToken(*connection, "keep-alive");
|
||||||
|
keepAlive = version == kVersion11 ? !close : (explicitKeep && !close);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ResolveFraming() {
|
||||||
|
const auto transferEncoding = Header("transfer-encoding");
|
||||||
|
const auto contentLength = Header("content-length");
|
||||||
|
if (transferEncoding && contentLength) {
|
||||||
|
// Ambiguous framing — the classic smuggling primitive.
|
||||||
|
throw HTTP1ProtocolError("both content-length and transfer-encoding present");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transferEncoding) {
|
||||||
|
auto codings = SplitCommaList(*transferEncoding);
|
||||||
|
if (codings.empty() || !EqualsIgnoreCase(codings.back(), "chunked")) {
|
||||||
|
throw HTTP1ProtocolError("unsupported transfer-encoding '"
|
||||||
|
+ std::string(*transferEncoding) + "'");
|
||||||
|
}
|
||||||
|
if (codings.size() > 1) {
|
||||||
|
throw HTTP1ProtocolError("stacked transfer-codings are not supported");
|
||||||
|
}
|
||||||
|
bodyMode = BodyMode::Chunked;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentLength) {
|
||||||
|
if (contentLength->empty()
|
||||||
|
|| !std::ranges::all_of(*contentLength, [](char c) { return c >= '0' && c <= '9'; })) {
|
||||||
|
throw HTTP1ProtocolError("malformed content-length '"
|
||||||
|
+ std::string(*contentLength) + "'");
|
||||||
|
}
|
||||||
|
std::uint64_t length = 0;
|
||||||
|
const auto result = std::from_chars(contentLength->data(),
|
||||||
|
contentLength->data() + contentLength->size(), length);
|
||||||
|
if (result.ec != std::errc{}) throw HTTP1ProtocolError("content-length out of range");
|
||||||
|
if (length > limits.maxBody) {
|
||||||
|
throw HTTP1ProtocolError("body exceeds " + std::to_string(limits.maxBody) + " bytes");
|
||||||
|
}
|
||||||
|
bodyRemaining = length;
|
||||||
|
bodyMode = length == 0 ? BodyMode::None : BodyMode::Length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No explicit framing. A request has no body; a response runs
|
||||||
|
// to connection close (RFC 9112 §6.3).
|
||||||
|
if (kind == MessageKind::Request) {
|
||||||
|
bodyMode = BodyMode::None;
|
||||||
|
} else {
|
||||||
|
bodyMode = BodyMode::UntilClose;
|
||||||
|
keepAlive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns false when this was an interim (1xx) response and parsing
|
||||||
|
// must restart on the next status line.
|
||||||
|
bool FinishHeaders() {
|
||||||
|
ResolveKeepAlive();
|
||||||
|
|
||||||
|
if (kind == MessageKind::Response) {
|
||||||
|
if (status[0] == '1') {
|
||||||
|
// Interim response (100 Continue, 103 Early Hints, …).
|
||||||
|
// It has no body and is not the answer to the request,
|
||||||
|
// so drop it and keep reading.
|
||||||
|
headers.clear();
|
||||||
|
lastHeaderName.clear();
|
||||||
|
status.clear();
|
||||||
|
version.clear();
|
||||||
|
headerBytes = 0;
|
||||||
|
state = State::StartLine;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (status == "204" || status == "304" || headRequest) {
|
||||||
|
bodyMode = BodyMode::None;
|
||||||
|
headersDone = true;
|
||||||
|
state = State::Done;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (auto expect = Header("expect")) {
|
||||||
|
expectContinue = HasToken(*expect, "100-continue");
|
||||||
|
}
|
||||||
|
|
||||||
|
ResolveFraming();
|
||||||
|
headersDone = true;
|
||||||
|
switch (bodyMode) {
|
||||||
|
case BodyMode::None: state = State::Done; break;
|
||||||
|
case BodyMode::Length: state = State::Body; break;
|
||||||
|
case BodyMode::Chunked: state = State::ChunkSize; break;
|
||||||
|
case BodyMode::UntilClose: state = State::Body; break;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppendBody(const char* data, std::size_t size) {
|
||||||
|
if (body.size() + size > limits.maxBody) {
|
||||||
|
throw HTTP1ProtocolError("body exceeds " + std::to_string(limits.maxBody) + " bytes");
|
||||||
|
}
|
||||||
|
body.append(data, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive the state machine as far as the buffered bytes allow, then
|
||||||
|
// drop what has been consumed so a long-lived connection (or a
|
||||||
|
// large body arriving in many chunks) doesn't grow the buffer
|
||||||
|
// without bound.
|
||||||
|
void Advance() {
|
||||||
|
AdvanceState();
|
||||||
|
if (pos != 0 && pos == buffer.size()) {
|
||||||
|
buffer.clear();
|
||||||
|
pos = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AdvanceState() {
|
||||||
|
for (;;) {
|
||||||
|
switch (state) {
|
||||||
|
case State::StartLine: {
|
||||||
|
auto line = TryReadLine(limits.maxStartLine);
|
||||||
|
if (!line) return;
|
||||||
|
// A server should tolerate stray empty lines left
|
||||||
|
// over from a previous message (RFC 9112 §2.2).
|
||||||
|
if (line->empty()) continue;
|
||||||
|
if (kind == MessageKind::Request) ParseRequestLine(*line);
|
||||||
|
else ParseStatusLine(*line);
|
||||||
|
state = State::Headers;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case State::Headers: {
|
||||||
|
auto line = TryReadLine(limits.maxHeaderSection);
|
||||||
|
if (!line) return;
|
||||||
|
if (line->empty()) {
|
||||||
|
FinishHeaders();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ParseHeaderLine(*line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case State::Body: {
|
||||||
|
const std::size_t available = buffer.size() - pos;
|
||||||
|
if (bodyMode == BodyMode::UntilClose) {
|
||||||
|
AppendBody(buffer.data() + pos, available);
|
||||||
|
pos = buffer.size();
|
||||||
|
if (!eof) return;
|
||||||
|
state = State::Done;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const std::size_t take = static_cast<std::size_t>(
|
||||||
|
std::min<std::uint64_t>(available, bodyRemaining));
|
||||||
|
AppendBody(buffer.data() + pos, take);
|
||||||
|
pos += take;
|
||||||
|
bodyRemaining -= take;
|
||||||
|
if (bodyRemaining != 0) {
|
||||||
|
if (eof) throw HTTP1ProtocolError("connection closed mid-body");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state = State::Done;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case State::ChunkSize: {
|
||||||
|
auto line = TryReadLine(limits.maxStartLine);
|
||||||
|
if (!line) return;
|
||||||
|
// Chunk extensions after ';' are legal and ignorable.
|
||||||
|
std::string_view digits = TrimOWS(line->substr(0, line->find(';')));
|
||||||
|
if (digits.empty()) throw HTTP1ProtocolError("empty chunk size");
|
||||||
|
std::uint64_t size = 0;
|
||||||
|
const auto result = std::from_chars(digits.data(), digits.data() + digits.size(),
|
||||||
|
size, 16);
|
||||||
|
if (result.ec != std::errc{} || result.ptr != digits.data() + digits.size()) {
|
||||||
|
throw HTTP1ProtocolError("malformed chunk size '" + std::string(digits) + "'");
|
||||||
|
}
|
||||||
|
if (size == 0) {
|
||||||
|
state = State::Trailers;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
chunkRemaining = size;
|
||||||
|
state = State::ChunkData;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case State::ChunkData: {
|
||||||
|
const std::size_t available = buffer.size() - pos;
|
||||||
|
if (available == 0) {
|
||||||
|
if (eof) throw HTTP1ProtocolError("connection closed mid-chunk");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const std::size_t take = static_cast<std::size_t>(
|
||||||
|
std::min<std::uint64_t>(available, chunkRemaining));
|
||||||
|
AppendBody(buffer.data() + pos, take);
|
||||||
|
pos += take;
|
||||||
|
chunkRemaining -= take;
|
||||||
|
if (chunkRemaining != 0) {
|
||||||
|
if (eof) throw HTTP1ProtocolError("connection closed mid-chunk");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state = State::ChunkTrailingCRLF;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case State::ChunkTrailingCRLF: {
|
||||||
|
auto line = TryReadLine(2);
|
||||||
|
if (!line) return;
|
||||||
|
if (!line->empty()) throw HTTP1ProtocolError("chunk not terminated by CRLF");
|
||||||
|
state = State::ChunkSize;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case State::Trailers: {
|
||||||
|
auto line = TryReadLine(limits.maxHeaderSection);
|
||||||
|
if (!line) return;
|
||||||
|
if (line->empty()) {
|
||||||
|
state = State::Done;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Trailer fields are merged into the header map;
|
||||||
|
// callers see one flat view of the message.
|
||||||
|
ParseHeaderLine(*line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case State::Done:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageKind kind;
|
||||||
|
MessageLimits limits;
|
||||||
|
State state = State::StartLine;
|
||||||
|
BodyMode bodyMode = BodyMode::None;
|
||||||
|
|
||||||
|
std::string buffer;
|
||||||
|
std::size_t pos = 0;
|
||||||
|
bool eof = false;
|
||||||
|
|
||||||
|
std::string method;
|
||||||
|
std::string path;
|
||||||
|
std::string scheme;
|
||||||
|
std::string authority;
|
||||||
|
std::string version;
|
||||||
|
std::string status;
|
||||||
|
std::string lastHeaderName;
|
||||||
|
std::unordered_map<std::string, std::string> headers;
|
||||||
|
std::string body;
|
||||||
|
|
||||||
|
std::size_t headerBytes = 0;
|
||||||
|
std::uint64_t bodyRemaining = 0;
|
||||||
|
std::uint64_t chunkRemaining = 0;
|
||||||
|
bool headersDone = false;
|
||||||
|
bool expectContinue = false;
|
||||||
|
bool keepAlive = true;
|
||||||
|
bool headRequest = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
76
interfaces/Crafter.Network-ListenerHTTP1.cppm
Normal file
76
interfaces/Crafter.Network-ListenerHTTP1.cppm
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
export module Crafter.Network:ListenerHTTP1;
|
||||||
|
import std;
|
||||||
|
import :HTTP;
|
||||||
|
import :HTTP1;
|
||||||
|
|
||||||
|
#ifndef CRAFTER_NETWORK_BROWSER
|
||||||
|
namespace Crafter {
|
||||||
|
// HTTP/1.1 server over plain TCP. Same route map shape as ListenerHTTP,
|
||||||
|
// so a handler can be registered with both and served over either
|
||||||
|
// protocol.
|
||||||
|
//
|
||||||
|
// Each accepted connection gets its own thread and is served
|
||||||
|
// sequentially until the peer closes it, `Connection: close` is seen, or
|
||||||
|
// an idle/read timeout expires. Handlers therefore run concurrently
|
||||||
|
// across connections but never for two requests on the same one, which
|
||||||
|
// is what HTTP/1.1 response ordering requires. A dedicated thread rather
|
||||||
|
// than a ThreadPool task is deliberate: keep-alive connections are idle
|
||||||
|
// most of their life and would otherwise pin every pool thread.
|
||||||
|
//
|
||||||
|
// Implemented: keep-alive and pipelining, content-length and chunked
|
||||||
|
// request bodies, `Expect: 100-continue`, HEAD (headers only), automatic
|
||||||
|
// `Date`, and 400/404/500 error responses. Not implemented: TLS,
|
||||||
|
// CONNECT tunnels, Upgrade, and chunked *responses* (handlers return a
|
||||||
|
// complete body, so responses are always content-length framed).
|
||||||
|
export class ListenerHTTP1 {
|
||||||
|
public:
|
||||||
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
|
||||||
|
|
||||||
|
// How long a connection may stay idle between requests, and how long
|
||||||
|
// a single request may take to arrive once started. Both guard
|
||||||
|
// against a peer holding a thread forever.
|
||||||
|
std::chrono::milliseconds keepAliveTimeout{15000};
|
||||||
|
std::chrono::milliseconds requestTimeout{30000};
|
||||||
|
// Limits applied to incoming requests.
|
||||||
|
HTTP1::MessageLimits limits;
|
||||||
|
|
||||||
|
ListenerHTTP1(std::uint16_t port,
|
||||||
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes);
|
||||||
|
|
||||||
|
~ListenerHTTP1();
|
||||||
|
ListenerHTTP1(const ListenerHTTP1&) = delete;
|
||||||
|
ListenerHTTP1(ListenerHTTP1&&) noexcept;
|
||||||
|
|
||||||
|
// Run the accept loop on the calling thread until Stop().
|
||||||
|
void Listen();
|
||||||
|
// Stop accepting, close every live connection, and wait for the
|
||||||
|
// connection threads to finish.
|
||||||
|
void Stop();
|
||||||
|
|
||||||
|
// Number of connections currently being served.
|
||||||
|
std::size_t ConnectionCount() const;
|
||||||
|
// Connections accepted since construction.
|
||||||
|
std::uint64_t AcceptedCount() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Impl;
|
||||||
|
std::unique_ptr<Impl> impl;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Runs the accept loop on a background thread so the caller can keep
|
||||||
|
// going. Mirrors ListenerAsyncHTTP.
|
||||||
|
export class ListenerAsyncHTTP1 {
|
||||||
|
public:
|
||||||
|
ListenerHTTP1 listener;
|
||||||
|
std::thread thread;
|
||||||
|
|
||||||
|
ListenerAsyncHTTP1(std::uint16_t port,
|
||||||
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes);
|
||||||
|
~ListenerAsyncHTTP1();
|
||||||
|
void Stop();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
@ -17,4 +17,10 @@ export import :WebTransport;
|
||||||
// not need the HTTP/3 frame helpers directly. Excluded from the browser
|
// not need the HTTP/3 frame helpers directly. Excluded from the browser
|
||||||
// build — HTTP3 uses throw and the wasm target runs with -fno-exceptions.
|
// build — HTTP3 uses throw and the wasm target runs with -fno-exceptions.
|
||||||
export import :HTTP3;
|
export import :HTTP3;
|
||||||
|
// HTTP/1.1 over TCP, for peers that are not ready for HTTP/3. Native only:
|
||||||
|
// in the browser this job is already done by fetch() behind :ClientHTTP,
|
||||||
|
// and these partitions use exceptions and POSIX sockets.
|
||||||
|
export import :HTTP1;
|
||||||
|
export import :ClientHTTP1;
|
||||||
|
export import :ListenerHTTP1;
|
||||||
#endif
|
#endif
|
||||||
18
project.cpp
18
project.cpp
|
|
@ -7,13 +7,16 @@ namespace fs = std::filesystem;
|
||||||
using namespace Crafter;
|
using namespace Crafter;
|
||||||
|
|
||||||
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
||||||
constexpr std::array<std::string_view, 10> networkInterfaces = {
|
constexpr std::array<std::string_view, 13> networkInterfaces = {
|
||||||
"interfaces/Crafter.Network",
|
"interfaces/Crafter.Network",
|
||||||
"interfaces/Crafter.Network-ClientTCP",
|
"interfaces/Crafter.Network-ClientTCP",
|
||||||
"interfaces/Crafter.Network-ListenerTCP",
|
"interfaces/Crafter.Network-ListenerTCP",
|
||||||
"interfaces/Crafter.Network-ClientHTTP",
|
"interfaces/Crafter.Network-ClientHTTP",
|
||||||
"interfaces/Crafter.Network-ListenerHTTP",
|
"interfaces/Crafter.Network-ListenerHTTP",
|
||||||
"interfaces/Crafter.Network-HTTP",
|
"interfaces/Crafter.Network-HTTP",
|
||||||
|
"interfaces/Crafter.Network-HTTP1",
|
||||||
|
"interfaces/Crafter.Network-ClientHTTP1",
|
||||||
|
"interfaces/Crafter.Network-ListenerHTTP1",
|
||||||
"interfaces/Crafter.Network-HTTP3",
|
"interfaces/Crafter.Network-HTTP3",
|
||||||
"interfaces/Crafter.Network-ClientQUIC",
|
"interfaces/Crafter.Network-ClientQUIC",
|
||||||
"interfaces/Crafter.Network-ListenerQUIC",
|
"interfaces/Crafter.Network-ListenerQUIC",
|
||||||
|
|
@ -69,11 +72,13 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
constexpr std::array<std::string_view, 7> networkImplementations = {
|
constexpr std::array<std::string_view, 9> networkImplementations = {
|
||||||
"implementations/Crafter.Network-ClientTCP",
|
"implementations/Crafter.Network-ClientTCP",
|
||||||
"implementations/Crafter.Network-ListenerTCP",
|
"implementations/Crafter.Network-ListenerTCP",
|
||||||
"implementations/Crafter.Network-ClientHTTP",
|
"implementations/Crafter.Network-ClientHTTP",
|
||||||
"implementations/Crafter.Network-ListenerHTTP",
|
"implementations/Crafter.Network-ListenerHTTP",
|
||||||
|
"implementations/Crafter.Network-ClientHTTP1",
|
||||||
|
"implementations/Crafter.Network-ListenerHTTP1",
|
||||||
"implementations/Crafter.Network-ClientQUIC",
|
"implementations/Crafter.Network-ClientQUIC",
|
||||||
"implementations/Crafter.Network-ListenerQUIC",
|
"implementations/Crafter.Network-ListenerQUIC",
|
||||||
"implementations/Crafter.Network-WebTransport",
|
"implementations/Crafter.Network-WebTransport",
|
||||||
|
|
@ -103,9 +108,9 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
// linker at the actual output location.
|
// linker at the actual output location.
|
||||||
msquic.libDirs = { "bin/Release" };
|
msquic.libDirs = { "bin/Release" };
|
||||||
msquic.libs = { "msquic" };
|
msquic.libs = { "msquic" };
|
||||||
std::array<fs::path, 10> ifaces;
|
std::array<fs::path, 13> ifaces;
|
||||||
std::ranges::copy(networkInterfaces, ifaces.begin());
|
std::ranges::copy(networkInterfaces, ifaces.begin());
|
||||||
std::array<fs::path, 7> impls;
|
std::array<fs::path, 9> impls;
|
||||||
std::ranges::copy(networkImplementations, impls.begin());
|
std::ranges::copy(networkImplementations, impls.begin());
|
||||||
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
|
||||||
|
|
@ -114,8 +119,13 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
// crafter-network static lib via .Dependencies({ &cfg }).
|
// crafter-network static lib via .Dependencies({ &cfg }).
|
||||||
if (cfg.target == "x86_64-pc-linux-gnu") {
|
if (cfg.target == "x86_64-pc-linux-gnu") {
|
||||||
cfg.AddTest("ShouldEchoWebTransport").Dependencies({ &cfg });
|
cfg.AddTest("ShouldEchoWebTransport").Dependencies({ &cfg });
|
||||||
|
cfg.AddTest("ShouldInteropCurlHTTP1").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldNotDropEarlyStreams").Dependencies({ &cfg });
|
cfg.AddTest("ShouldNotDropEarlyStreams").Dependencies({ &cfg });
|
||||||
|
cfg.AddTest("ShouldParseHTTP1").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldSend").Dependencies({ &cfg });
|
cfg.AddTest("ShouldSend").Dependencies({ &cfg });
|
||||||
|
cfg.AddTest("ShouldSendRecieveHTTP1").Dependencies({ &cfg });
|
||||||
|
cfg.AddTest("ShouldSendRecieveKeepaliveHTTP1").Dependencies({ &cfg });
|
||||||
|
cfg.AddTest("ShouldSendRecieveLargeHTTP1").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldSendRecieveHTTP").Dependencies({ &cfg });
|
cfg.AddTest("ShouldSendRecieveHTTP").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldSendRecieveKeepaliveHTTP").Dependencies({ &cfg });
|
cfg.AddTest("ShouldSendRecieveKeepaliveHTTP").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldSendRecieveLargeHTTP").Dependencies({ &cfg });
|
cfg.AddTest("ShouldSendRecieveLargeHTTP").Dependencies({ &cfg });
|
||||||
|
|
|
||||||
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