diff options
| author | Stefan Boberg <[email protected]> | 2026-03-10 17:27:26 +0100 |
|---|---|---|
| committer | GitHub Enterprise <[email protected]> | 2026-03-10 17:27:26 +0100 |
| commit | d0a07e555577dcd4a8f55f1b45d9e8e4e6366ab7 (patch) | |
| tree | 2dfe1e3e0b620043d358e0b7f8bdf8320d985491 /src/zenhttp/httpclient_test.cpp | |
| parent | changelog entry which was inadvertently omitted from PR merge (diff) | |
| download | zen-d0a07e555577dcd4a8f55f1b45d9e8e4e6366ab7.tar.xz zen-d0a07e555577dcd4a8f55f1b45d9e8e4e6366ab7.zip | |
HttpClient using libcurl, Unix Sockets for HTTP. HTTPS support (#770)
The main goal of this change is to eliminate the cpr back-end altogether and replace it with the curl implementation. I would expect to drop cpr as soon as we feel happy with the libcurl back-end. That would leave us with a direct dependency on libcurl only, and cpr can be eliminated as a dependency.
### HttpClient Backend Overhaul
- Implemented a new **libcurl-based HttpClient** backend (`httpclientcurl.cpp`, ~2000 lines)
as an alternative to the cpr-based one
- Made HttpClient backend **configurable at runtime** via constructor arguments
and `-httpclient=...` CLI option (for zen, zenserver, and tests)
- Extended HttpClient test suite to cover multipart/content-range scenarios
### Unix Domain Socket Support
- Added Unix domain socket support to **httpasio** (server side)
- Added Unix domain socket support to **HttpClient**
- Added Unix domain socket support to **HttpWsClient** (WebSocket client)
- Templatized `HttpServerConnectionT<SocketType>` and `WsAsioConnectionT<SocketType>`
to handle TCP, Unix, and SSL sockets uniformly via `if constexpr` dispatch
### HTTPS Support
- Added **preliminary HTTPS support to httpasio** (for Mac/Linux via OpenSSL)
- Added **basic HTTPS support for http.sys** (Windows)
- Implemented HTTPS test for httpasio
- Split `InitializeServer` into smaller sub-functions for http.sys
### Other Notable Changes
- Improved **zenhttp-test stability** with dynamic port allocation
- Enhanced port retry logic in http.sys (handles ERROR_ACCESS_DENIED)
- Fatal signal/exception handlers for backtrace generation in tests
- Added `zen bench http` subcommand to exercise network + HTTP client/server communication stack
Diffstat (limited to 'src/zenhttp/httpclient_test.cpp')
| -rw-r--r-- | src/zenhttp/httpclient_test.cpp | 299 |
1 files changed, 295 insertions, 4 deletions
diff --git a/src/zenhttp/httpclient_test.cpp b/src/zenhttp/httpclient_test.cpp index 52bf149a7..2d949c546 100644 --- a/src/zenhttp/httpclient_test.cpp +++ b/src/zenhttp/httpclient_test.cpp @@ -8,6 +8,7 @@ # include <zencore/compactbinarybuilder.h> # include <zencore/compactbinaryutil.h> # include <zencore/compositebuffer.h> +# include <zencore/filesystem.h> # include <zencore/iobuffer.h> # include <zencore/logging.h> # include <zencore/scopeguard.h> @@ -232,7 +233,7 @@ struct TestServerFixture TestServerFixture() { Server = CreateHttpAsioServer(AsioConfig{}); - Port = Server->Initialize(7600, TmpDir.Path()); + Port = Server->Initialize(0, TmpDir.Path()); ZEN_ASSERT(Port != -1); Server->RegisterService(TestService); ServerThread = std::thread([this]() { Server->Run(false); }); @@ -1044,13 +1045,22 @@ struct FaultTcpServer { m_Port = m_Acceptor.local_endpoint().port(); StartAccept(); - m_Thread = std::thread([this]() { m_IoContext.run(); }); + m_Thread = std::thread([this]() { + try + { + m_IoContext.run(); + } + catch (...) + { + } + }); } ~FaultTcpServer() { - std::error_code Ec; - m_Acceptor.close(Ec); + // io_context::stop() is thread-safe; do NOT call m_Acceptor.close() from this + // thread — ASIO I/O objects are not safe for concurrent access and the io_context + // thread may be touching the acceptor in StartAccept(). m_IoContext.stop(); if (m_Thread.joinable()) { @@ -1081,6 +1091,105 @@ struct FaultTcpServer } }; +TEST_CASE("httpclient.range-response") +{ + ScopedTemporaryDirectory DownloadDir; + + SUBCASE("single range 206 response populates Ranges") + { + std::string RangeBody(100, 'A'); + + FaultTcpServer Server([&](asio::ip::tcp::socket& Socket) { + DrainHttpRequest(Socket); + std::string Response = fmt::format( + "HTTP/1.1 206 Partial Content\r\n" + "Content-Type: application/octet-stream\r\n" + "Content-Range: bytes 200-299/1000\r\n" + "Content-Length: {}\r\n" + "\r\n" + "{}", + RangeBody.size(), + RangeBody); + std::error_code Ec; + asio::write(Socket, asio::buffer(Response), Ec); + }); + + HttpClient Client = Server.MakeClient(); + HttpClient::Response Resp = Client.Download("/test", DownloadDir.Path()); + CHECK(Resp.IsSuccess()); + CHECK_EQ(Resp.StatusCode, HttpResponseCode::PartialContent); + REQUIRE(Resp.Ranges.size() == 1); + CHECK_EQ(Resp.Ranges[0].RangeOffset, 200); + CHECK_EQ(Resp.Ranges[0].RangeLength, 100); + } + + SUBCASE("multipart byteranges 206 response populates Ranges") + { + std::string Part1Data(16, 'X'); + std::string Part2Data(12, 'Y'); + std::string Boundary = "testboundary123"; + + std::string MultipartBody = fmt::format( + "\r\n--{}\r\n" + "Content-Type: application/octet-stream\r\n" + "Content-Range: bytes 100-115/1000\r\n" + "\r\n" + "{}" + "\r\n--{}\r\n" + "Content-Type: application/octet-stream\r\n" + "Content-Range: bytes 500-511/1000\r\n" + "\r\n" + "{}" + "\r\n--{}--", + Boundary, + Part1Data, + Boundary, + Part2Data, + Boundary); + + FaultTcpServer Server([&](asio::ip::tcp::socket& Socket) { + DrainHttpRequest(Socket); + std::string Response = fmt::format( + "HTTP/1.1 206 Partial Content\r\n" + "Content-Type: multipart/byteranges; boundary={}\r\n" + "Content-Length: {}\r\n" + "\r\n" + "{}", + Boundary, + MultipartBody.size(), + MultipartBody); + std::error_code Ec; + asio::write(Socket, asio::buffer(Response), Ec); + }); + + HttpClient Client = Server.MakeClient(); + HttpClient::Response Resp = Client.Download("/test", DownloadDir.Path()); + CHECK(Resp.IsSuccess()); + CHECK_EQ(Resp.StatusCode, HttpResponseCode::PartialContent); + REQUIRE(Resp.Ranges.size() == 2); + // Ranges should be sorted by RangeOffset + CHECK_EQ(Resp.Ranges[0].RangeOffset, 100); + CHECK_EQ(Resp.Ranges[0].RangeLength, 16); + CHECK_EQ(Resp.Ranges[1].RangeOffset, 500); + CHECK_EQ(Resp.Ranges[1].RangeLength, 12); + } + + SUBCASE("non-range 200 response has empty Ranges") + { + FaultTcpServer Server([&](asio::ip::tcp::socket& Socket) { + DrainHttpRequest(Socket); + std::string Response = MakeRawHttpResponse(200, "full content"); + std::error_code Ec; + asio::write(Socket, asio::buffer(Response), Ec); + }); + + HttpClient Client = Server.MakeClient(); + HttpClient::Response Resp = Client.Download("/test", DownloadDir.Path()); + CHECK(Resp.IsSuccess()); + CHECK(Resp.Ranges.empty()); + } +} + TEST_CASE("httpclient.transport-faults" * doctest::skip()) { SUBCASE("connection reset before response") @@ -1354,6 +1463,188 @@ TEST_CASE("httpclient.transport-faults-post" * doctest::skip()) } } +TEST_CASE("httpclient.unixsocket") +{ + ScopedTemporaryDirectory TmpDir; + std::string SocketPath = (TmpDir.Path() / "zen.sock").string(); + + HttpClientTestService TestService; + + Ref<HttpServer> Server = CreateHttpAsioServer(AsioConfig{.UnixSocketPath = SocketPath}); + + int Port = Server->Initialize(0, TmpDir.Path()); + REQUIRE(Port != -1); + + Server->RegisterService(TestService); + + std::thread ServerThread([&]() { Server->Run(false); }); + + auto _ = MakeGuard([&]() { + Server->RequestExit(); + if (ServerThread.joinable()) + { + ServerThread.join(); + } + Server->Close(); + }); + + HttpClientSettings Settings; + Settings.UnixSocketPath = SocketPath; + + HttpClient Client("localhost", Settings, /*CheckIfAbortFunction*/ {}); + + SUBCASE("GET over unix socket") + { + HttpClient::Response Resp = Client.Get("/api/test/hello"); + CHECK(Resp.IsSuccess()); + CHECK_EQ(Resp.AsText(), "hello world"); + } + + SUBCASE("POST echo over unix socket") + { + const char* Payload = "unix socket payload"; + IoBuffer Body(IoBuffer::Clone, Payload, strlen(Payload)); + Body.SetContentType(ZenContentType::kText); + + HttpClient::Response Resp = Client.Post("/api/test/echo", Body); + CHECK(Resp.IsSuccess()); + CHECK_EQ(Resp.AsText(), "unix socket payload"); + } +} + +# if ZEN_USE_OPENSSL + +TEST_CASE("httpclient.https") +{ + // Self-signed test certificate for localhost/127.0.0.1, valid until 2036 + static constexpr std::string_view TestCertPem = + "-----BEGIN CERTIFICATE-----\n" + "MIIDJTCCAg2gAwIBAgIUEtJYMSUmJmvJ157We/qXNVJ7W8gwDQYJKoZIhvcNAQEL\n" + "BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDMwOTIwMjU1M1oXDTM2MDMw\n" + "NjIwMjU1M1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF\n" + "AAOCAQ8AMIIBCgKCAQEAv9YvZ6WeBz3z/Zuxi6OIivWksDxDZZ5oAXKVwlUXaa7v\n" + "iDkm9P5ZsEhN+M5vZMe2Yb9i3cnTUaE6Avs1ddOwTAYNGrE/B5DmibrRWc23R0cv\n" + "gdnYQJ+gjsAeMvUWYLK58xW4YoMR5bmfpj1ruqobUNkG/oJYnAUcjgo4J149irW+\n" + "4n9uLJvxL+5fI/b/AIkv+4TMe70/d/BPmnixWrrzxUT6S5ghE2Mq7+XLScfpY2Sp\n" + "GQ/Xbnj9/ELYLpQnNLuVZwWZDpXj+FLbF1zxgjYdw1cCjbRcOIEW2/GJeJvGXQ6Y\n" + "Vld5pCBm9uKPPLWoFCoakK5YvP00h+8X+HghGVSscQIDAQABo28wbTAdBgNVHQ4E\n" + "FgQUgM6hjymi6g2EBUg2ENu0nIK8yhMwHwYDVR0jBBgwFoAUgM6hjymi6g2EBUg2\n" + "ENu0nIK8yhMwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARhwR/AAABgglsb2Nh\n" + "bGhvc3QwDQYJKoZIhvcNAQELBQADggEBABY1oaaWwL4RaK/epKvk/IrmVT2mlAai\n" + "uvGLfjhc6FGvXaxPGTSUPrVbFornaWZAg7bOWCexWnEm2sWd75V/usvZAPN4aIiD\n" + "H66YQipq3OD4F9Gowp01IU4AcGh7MerFpYPk76+wp2ANq71x8axtlZjVn3hSFMmN\n" + "i6m9S/eyCl9WjYBT5ZEC4fJV0nOSmNe/+gCAm11/js9zNfXKmUchJtuZpubY3A0k\n" + "X2II6qYWf1PH+JJkefNZtt2c66CrEN5eAg4/rGEgsp43zcd4ZHVkpBKFLDEls1ev\n" + "drQ45zc4Ht77pHfnHu7YsLcRZ9Wq3COMNZYx5lItqnomX2qBm1pkwjI=\n" + "-----END CERTIFICATE-----\n"; + + static constexpr std::string_view TestKeyPem = + "-----BEGIN PRIVATE KEY-----\n" + "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC/1i9npZ4HPfP9\n" + "m7GLo4iK9aSwPENlnmgBcpXCVRdpru+IOSb0/lmwSE34zm9kx7Zhv2LdydNRoToC\n" + "+zV107BMBg0asT8HkOaJutFZzbdHRy+B2dhAn6COwB4y9RZgsrnzFbhigxHluZ+m\n" + "PWu6qhtQ2Qb+glicBRyOCjgnXj2Ktb7if24sm/Ev7l8j9v8AiS/7hMx7vT938E+a\n" + "eLFauvPFRPpLmCETYyrv5ctJx+ljZKkZD9dueP38QtgulCc0u5VnBZkOleP4UtsX\n" + "XPGCNh3DVwKNtFw4gRbb8Yl4m8ZdDphWV3mkIGb24o88tagUKhqQrli8/TSH7xf4\n" + "eCEZVKxxAgMBAAECggEAILd9pDaZqfCF8SWhdQgx3Ekiii/s6qLGaCDLq7XpZUvB\n" + "bEEbBMNwNmFOcvV6B/0LfMYwLVUjZhOSGjoPlwXAVmbdy0SZVEgBGVI0LBWqgUyB\n" + "rKqjd/oBXvci71vfMiSpE+0LYjmqTryGnspw2gfy2qn4yGUgiZNRmGPjycsHweUL\n" + "V3FHm3cf0dyE4sJ0mjVqZzRT/unw2QOCE6FlY7M1XxZL88IWfn6G4lckdJTwoOP5\n" + "VPR2J3XbyhvCeXeDRCHKRXojWWR2HovWnDXQc95GRgCd0vYdHuIUM6RXVPZQvy3X\n" + "l0GwQKHNcVr1uwtYDgGKw0tNCUDvxdfQaWilTFuicQKBgQDvEYp+vL1hnF+AVdu3\n" + "elsYsHpFgExkTI8wnUMvGZrFiIQyCyVDU3jkG3kcKacI1bfwopXopaQCjrYk9epm\n" + "liOVm3/Xtr6e2ENa7w8TQbdK65PciQNOMxml6g8clRRBl0cwj+aI3nW/Kop1cdrR\n" + "A9Vo+8iPTO5gDcxTiIb45a6E3QKBgQDNbE009P6ewx9PU7Llkhb9VBgsb7oQN3EV\n" + "TCYd4taiN6FPnTuL/cdijAA8y04hiVT+Efo9TUN9NCl9HdHXQcjj7/n/eFLH0Pkw\n" + "OIK3QN49OfR88wivLMtwWxIog0tJjc9+7dR4bR4o1jTlIrasEIvUTuDJQ8MKGc9v\n" + "pBITua+SpQKBgE4raSKZqj7hd6Sp7kbnHiRLiB9znQbqtaNKuK4M7DuMsNUAKfYC\n" + "tDO5+/bGc9SCtTtcnjHM/3zKlyossrFKhGYlyz6IhXnA8v0nz8EXKsy3jMh+kHMg\n" + "aFGE394TrOTphyCM3O+B9fRE/7L5QHg5ja1fLqwUlpkXyejCaoe16kONAoGAYIz9\n" + "wN1B67cEOVG6rOI8QfdLoV8mEcctNHhlFfjvLrF89SGOwl6WX0A0QF7CK0sUEpK6\n" + "jiOJjAh/U5o3bbgyxsedNjEEn3weE0cMUTuA+UALJMtKEqO4PuffIgGL2ld35k28\n" + "ZpnK6iC8HdJyD297eV9VkeNygYXeFLgF8xV8ay0CgYEAh4fmVZt9YhgVByYny2kF\n" + "ZUIkGF5h9wxzVOPpQwpizIGFFb3i/ZdGQcuLTfIBVRKf50sT3IwJe65ATv6+Lz0f\n" + "wg/pMvosi0/F5KGbVRVdzBMQy58WyyGti4tNl+8EXGvo8+DCmjlTYwfjRoZGg/qJ\n" + "EMP3/hTN7dHDRxPK8E0Fh0Y=\n" + "-----END PRIVATE KEY-----\n"; + + ScopedTemporaryDirectory TmpDir; + + // Write cert and key to temp files + const auto CertPath = TmpDir.Path() / "test.crt"; + const auto KeyPath = TmpDir.Path() / "test.key"; + WriteFile(CertPath, IoBuffer(IoBuffer::Clone, TestCertPem.data(), TestCertPem.size())); + WriteFile(KeyPath, IoBuffer(IoBuffer::Clone, TestKeyPem.data(), TestKeyPem.size())); + + HttpClientTestService TestService; + + AsioConfig Config; + Config.CertFile = CertPath.string(); + Config.KeyFile = KeyPath.string(); + + Ref<HttpServer> Server = CreateHttpAsioServer(Config); + + int Port = Server->Initialize(0, TmpDir.Path()); + REQUIRE(Port != -1); + + Server->RegisterService(TestService); + + std::thread ServerThread([&]() { Server->Run(false); }); + + auto _ = MakeGuard([&]() { + Server->RequestExit(); + if (ServerThread.joinable()) + { + ServerThread.join(); + } + Server->Close(); + }); + + int HttpsPort = Server->GetEffectiveHttpsPort(); + REQUIRE(HttpsPort > 0); + + HttpClientSettings Settings; + Settings.InsecureSsl = true; + + HttpClient Client(fmt::format("https://127.0.0.1:{}", HttpsPort), Settings, /*CheckIfAbortFunction*/ {}); + + SUBCASE("GET over HTTPS") + { + HttpClient::Response Resp = Client.Get("/api/test/hello"); + CHECK(Resp.IsSuccess()); + CHECK_EQ(Resp.AsText(), "hello world"); + } + + SUBCASE("POST echo over HTTPS") + { + const char* Payload = "https payload"; + IoBuffer Body(IoBuffer::Clone, Payload, strlen(Payload)); + Body.SetContentType(ZenContentType::kText); + + HttpClient::Response Resp = Client.Post("/api/test/echo", Body); + CHECK(Resp.IsSuccess()); + CHECK_EQ(Resp.AsText(), "https payload"); + } + + SUBCASE("GET JSON over HTTPS") + { + HttpClient::Response Resp = Client.Get("/api/test/json"); + CHECK(Resp.IsSuccess()); + CbObject Obj = Resp.AsObject(); + CHECK_EQ(Obj["ok"].AsBool(), true); + CHECK_EQ(Obj["message"].AsString(), "test"); + } + + SUBCASE("Large payload over HTTPS") + { + HttpClient::Response Resp = Client.Get("/api/test/large"); + CHECK(Resp.IsSuccess()); + CHECK_EQ(Resp.ResponsePayload.GetSize(), 64u * 1024u); + } +} + +# endif // ZEN_USE_OPENSSL + TEST_SUITE_END(); void |