157 lines
6.7 KiB
C++
157 lines
6.7 KiB
C++
|
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||
|
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||
|
|
|
||
|
|
// A route table whose paths cannot be enumerated up front — `/order/<token>`,
|
||
|
|
// `/shop/<slug>` — is served through the listeners' `fallback` handler. The
|
||
|
|
// same table is registered with ListenerHTTP1 and ListenerHTTP and asked the
|
||
|
|
// same questions, because the point of the feature is that a URL means the
|
||
|
|
// same thing over either protocol.
|
||
|
|
|
||
|
|
import Crafter.Network;
|
||
|
|
import Crafter.Thread;
|
||
|
|
import std;
|
||
|
|
using namespace Crafter;
|
||
|
|
|
||
|
|
namespace {
|
||
|
|
constexpr std::uint16_t kPortHTTP1 = 8096;
|
||
|
|
constexpr std::uint16_t kPortHTTP1Plain = 8097;
|
||
|
|
constexpr std::uint16_t kPortHTTP3 = 8098;
|
||
|
|
|
||
|
|
int failures = 0;
|
||
|
|
|
||
|
|
void Check(bool condition, std::string_view what) {
|
||
|
|
if (!condition) {
|
||
|
|
std::println("FAIL: {}", what);
|
||
|
|
++failures;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Every exchange the test performs, phrased so it can be replayed against
|
||
|
|
// either protocol. `body` is matched exactly; an empty expectation means
|
||
|
|
// "don't care".
|
||
|
|
struct Exchange {
|
||
|
|
std::string_view what;
|
||
|
|
std::string_view path;
|
||
|
|
std::string_view status;
|
||
|
|
std::string_view body;
|
||
|
|
};
|
||
|
|
|
||
|
|
constexpr std::array<Exchange, 8> kExchanges = {{
|
||
|
|
// A registered route still wins: fallback only sees what routes miss.
|
||
|
|
{"an exact route beats the fallback", "/", "200", "root"},
|
||
|
|
{"query strings still route to the path", "/?utm=1", "200", "root"},
|
||
|
|
// ...and everything else reaches the fallback with the target intact.
|
||
|
|
{"an unknown path reaches the fallback", "/shop/blue-mug", "200", "slug:blue-mug"},
|
||
|
|
{"a second segment value reaches it too", "/shop/red-mug", "200", "slug:red-mug"},
|
||
|
|
{"the fallback sees the full target", "/shop/mug?ref=x", "200", "slug:mug?ref=x"},
|
||
|
|
{"the fallback may answer 404 itself", "/shop/", "404", "no such product"},
|
||
|
|
{"the fallback may answer non-404", "/order/deadbeef", "303", ""},
|
||
|
|
// A throwing fallback must be contained exactly like a throwing route.
|
||
|
|
{"a throwing fallback becomes a 500", "/boom", "500", ""},
|
||
|
|
}};
|
||
|
|
|
||
|
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> MakeRoutes() {
|
||
|
|
return {
|
||
|
|
{"/", [](const HTTPRequest&) { return CreateResponseHTTP("200", "root"); }},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Stands in for an application router: the set of valid slugs and order
|
||
|
|
// tokens is only known at runtime, so none of these paths could have been
|
||
|
|
// registered in `routes`.
|
||
|
|
HTTPResponse Fallback(const HTTPRequest& request) {
|
||
|
|
const std::string_view path = PathWithoutQueryHTTP(request.path);
|
||
|
|
if (path == "/boom") throw std::runtime_error("fallback exploded");
|
||
|
|
if (path.starts_with("/order/")) {
|
||
|
|
return CreateResponseHTTP("303", {{"location", "/"}}, "");
|
||
|
|
}
|
||
|
|
if (path.starts_with("/shop/")) {
|
||
|
|
if (path.size() == std::string_view("/shop/").size()) {
|
||
|
|
return CreateResponseHTTP("404", "no such product");
|
||
|
|
}
|
||
|
|
// The full target, query string and all, reached the handler.
|
||
|
|
return CreateResponseHTTP("200", std::format("slug:{}",
|
||
|
|
request.path.substr(std::string_view("/shop/").size())));
|
||
|
|
}
|
||
|
|
return CreateResponseHTTP("404", "unrouted");
|
||
|
|
}
|
||
|
|
|
||
|
|
void Replay(std::string_view protocol,
|
||
|
|
const std::function<HTTPResponse(std::string_view)>& send) {
|
||
|
|
for (const Exchange& exchange : kExchanges) {
|
||
|
|
HTTPResponse response = send(exchange.path);
|
||
|
|
Check(response.status == exchange.status,
|
||
|
|
std::format("{}: {} (status {}, wanted {})",
|
||
|
|
protocol, exchange.what, response.status, exchange.status));
|
||
|
|
if (!exchange.body.empty()) {
|
||
|
|
Check(response.body == exchange.body,
|
||
|
|
std::format("{}: {} (body '{}', wanted '{}')",
|
||
|
|
protocol, exchange.what, response.body, exchange.body));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
int main() {
|
||
|
|
ThreadPool::Start();
|
||
|
|
|
||
|
|
// A hung read would otherwise stall the whole suite.
|
||
|
|
std::thread watchdog([] {
|
||
|
|
std::this_thread::sleep_for(std::chrono::seconds(30));
|
||
|
|
std::println("timed out");
|
||
|
|
std::cout.flush();
|
||
|
|
std::_Exit(1);
|
||
|
|
});
|
||
|
|
watchdog.detach();
|
||
|
|
|
||
|
|
try {
|
||
|
|
// ── HTTP/1.1 over TCP ────────────────────────────────────────────
|
||
|
|
{
|
||
|
|
ListenerAsyncHTTP1 listener(kPortHTTP1, MakeRoutes(), Fallback);
|
||
|
|
ClientHTTP1 client("localhost", kPortHTTP1);
|
||
|
|
Replay("http1", [&](std::string_view path) {
|
||
|
|
return client.Send(CreateRequestHTTP("GET", std::string(path), "localhost"));
|
||
|
|
});
|
||
|
|
|
||
|
|
// Without a fallback the listener keeps synthesising its own 404,
|
||
|
|
// so nothing about the default behaviour moved.
|
||
|
|
ListenerAsyncHTTP1 plain(kPortHTTP1Plain, MakeRoutes());
|
||
|
|
ClientHTTP1 plainClient("localhost", kPortHTTP1Plain);
|
||
|
|
HTTPResponse missing = plainClient.Send(
|
||
|
|
CreateRequestHTTP("GET", "/shop/blue-mug", "localhost"));
|
||
|
|
Check(missing.status == "404", "http1: no fallback still means a synthetic 404");
|
||
|
|
Check(missing.body == "Not Found", "http1: ...with the listener's own body");
|
||
|
|
plain.Stop();
|
||
|
|
|
||
|
|
listener.Stop();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── HTTP/3 over QUIC, same routes and same fallback ──────────────
|
||
|
|
{
|
||
|
|
QUICServerCredentials serverCreds;
|
||
|
|
serverCreds.selfSigned = true;
|
||
|
|
ListenerAsyncHTTP listener(kPortHTTP3, serverCreds, MakeRoutes(), Fallback);
|
||
|
|
|
||
|
|
QUICClientCredentials clientCreds;
|
||
|
|
clientCreds.insecureNoServerValidation = true;
|
||
|
|
ClientHTTP client("localhost", kPortHTTP3, clientCreds);
|
||
|
|
Replay("http3", [&](std::string_view path) {
|
||
|
|
return client.Send(CreateRequestHTTP("GET", std::string(path), "localhost"));
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} catch (const std::exception& error) {
|
||
|
|
std::println("threw: {}", error.what());
|
||
|
|
std::cout.flush();
|
||
|
|
std::_Exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (failures != 0) {
|
||
|
|
std::println("{} check(s) failed", failures);
|
||
|
|
std::cout.flush();
|
||
|
|
std::_Exit(1);
|
||
|
|
}
|
||
|
|
// See ShouldSendRecieveQUICStream: msquic's RegistrationClose blocks on
|
||
|
|
// outstanding connections, so skip graceful teardown once we are done.
|
||
|
|
std::cout.flush();
|
||
|
|
std::_Exit(0);
|
||
|
|
}
|