aboutsummaryrefslogtreecommitdiff
path: root/src/zenhttp/asynchttpclient_test.cpp
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-04-09 11:02:41 +0200
committerGitHub Enterprise <[email protected]>2026-04-09 11:02:41 +0200
commit5900f6a6d892fbe582c46063cc399a840e60ef2e (patch)
tree76735ff6de39c2c515a866ecc9d7b4309d63669d /src/zenhttp/asynchttpclient_test.cpp
parentmigrate from http_parser to llhttp (#929) (diff)
downloadzen-5900f6a6d892fbe582c46063cc399a840e60ef2e.tar.xz
zen-5900f6a6d892fbe582c46063cc399a840e60ef2e.zip
Add async HTTP client (curl_multi + ASIO) (#918)
- Adds `AsyncHttpClient` — an asynchronous HTTP client using `curl_multi_socket_action` integrated with ASIO for event-driven I/O. Supports GET, POST, PUT, DELETE, HEAD with both callback-based and `std::future`-based APIs. - Extracts shared curl helpers (callbacks, URL encoding, header construction, error mapping) into `httpclientcurlhelpers.h`, eliminating duplication between the sync and async implementations. ## Design - All curl_multi state is serialized on an `asio::strand`, safe with multi-threaded io_contexts. - Two construction modes: owned io_context (creates internal thread) or external io_context (caller runs the loop). - Socket readiness is detected via `asio::ip::tcp::socket::async_wait` driven by curl's `CURLMOPT_SOCKETFUNCTION`/`CURLMOPT_TIMERFUNCTION` — no polling, sub-millisecond latency. - Completion callbacks are dispatched off the strand onto the io_context so slow callbacks don't starve the curl event loop. Exceptions in callbacks are caught and logged. ## Files | File | Change | |------|--------| | `zenhttp/include/zenhttp/asynchttpclient.h` | New public header | | `zenhttp/clients/asynchttpclient.cpp` | Implementation (~1000 lines) | | `zenhttp/clients/httpclientcurlhelpers.h` | Shared curl helpers extracted from sync client | | `zenhttp/clients/httpclientcurl.cpp` | Removed duplicated helpers, uses shared header | | `zenhttp/asynchttpclient_test.cpp` | 8 test cases: verbs, payloads, callbacks, concurrency, external io_context, connection errors | | `zenhttp/zenhttp.cpp` | Forcelink registration for new tests |
Diffstat (limited to 'src/zenhttp/asynchttpclient_test.cpp')
-rw-r--r--src/zenhttp/asynchttpclient_test.cpp315
1 files changed, 315 insertions, 0 deletions
diff --git a/src/zenhttp/asynchttpclient_test.cpp b/src/zenhttp/asynchttpclient_test.cpp
new file mode 100644
index 000000000..151863370
--- /dev/null
+++ b/src/zenhttp/asynchttpclient_test.cpp
@@ -0,0 +1,315 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#include <zenhttp/asynchttpclient.h>
+#include <zenhttp/httpserver.h>
+
+#if ZEN_WITH_TESTS
+
+# include <zencore/iobuffer.h>
+# include <zencore/logging.h>
+# include <zencore/scopeguard.h>
+# include <zencore/testing.h>
+# include <zencore/testutils.h>
+
+# include "servers/httpasio.h"
+
+# include <atomic>
+# include <thread>
+
+ZEN_THIRD_PARTY_INCLUDES_START
+# include <asio.hpp>
+ZEN_THIRD_PARTY_INCLUDES_END
+
+namespace zen {
+
+using namespace std::literals;
+
+//////////////////////////////////////////////////////////////////////////
+// Reusable test service for async client tests
+
+class AsyncHttpClientTestService : public HttpService
+{
+public:
+ AsyncHttpClientTestService()
+ {
+ m_Router.RegisterRoute(
+ "hello",
+ [](HttpRouterRequest& Req) { Req.ServerRequest().WriteResponse(HttpResponseCode::OK, HttpContentType::kText, "hello world"); },
+ HttpVerb::kGet);
+
+ m_Router.RegisterRoute(
+ "echo",
+ [](HttpRouterRequest& Req) {
+ HttpServerRequest& HttpReq = Req.ServerRequest();
+ IoBuffer Body = HttpReq.ReadPayload();
+ HttpContentType CT = HttpReq.RequestContentType();
+ HttpReq.WriteResponse(HttpResponseCode::OK, CT, Body);
+ },
+ HttpVerb::kPost | HttpVerb::kPut);
+
+ m_Router.RegisterRoute(
+ "echo/method",
+ [](HttpRouterRequest& Req) {
+ HttpServerRequest& HttpReq = Req.ServerRequest();
+ std::string_view Method = ToString(HttpReq.RequestVerb());
+ HttpReq.WriteResponse(HttpResponseCode::OK, HttpContentType::kText, Method);
+ },
+ HttpVerb::kGet | HttpVerb::kPost | HttpVerb::kPut | HttpVerb::kDelete | HttpVerb::kHead);
+
+ m_Router.RegisterRoute(
+ "nocontent",
+ [](HttpRouterRequest& Req) { Req.ServerRequest().WriteResponse(HttpResponseCode::NoContent); },
+ HttpVerb::kGet | HttpVerb::kPost | HttpVerb::kPut | HttpVerb::kDelete);
+
+ m_Router.RegisterRoute(
+ "json",
+ [](HttpRouterRequest& Req) {
+ Req.ServerRequest().WriteResponse(HttpResponseCode::OK, HttpContentType::kJSON, "{\"ok\":true}");
+ },
+ HttpVerb::kGet);
+ }
+
+ virtual const char* BaseUri() const override { return "/api/async-test/"; }
+ virtual void HandleRequest(HttpServerRequest& Request) override { m_Router.HandleRequest(Request); }
+
+private:
+ HttpRequestRouter m_Router;
+};
+
+//////////////////////////////////////////////////////////////////////////
+
+struct AsyncTestServerFixture
+{
+ AsyncHttpClientTestService TestService;
+ ScopedTemporaryDirectory TmpDir;
+ Ref<HttpServer> Server;
+ std::thread ServerThread;
+ int Port = -1;
+
+ AsyncTestServerFixture()
+ {
+ Server = CreateHttpAsioServer(AsioConfig{});
+ Port = Server->Initialize(0, TmpDir.Path());
+ ZEN_ASSERT(Port != -1);
+ Server->RegisterService(TestService);
+ ServerThread = std::thread([this]() { Server->Run(false); });
+ }
+
+ ~AsyncTestServerFixture()
+ {
+ Server->RequestExit();
+ if (ServerThread.joinable())
+ {
+ ServerThread.join();
+ }
+ Server->Close();
+ }
+
+ AsyncHttpClient MakeClient(HttpClientSettings Settings = {}) { return AsyncHttpClient(fmt::format("127.0.0.1:{}", Port), Settings); }
+
+ AsyncHttpClient MakeClient(asio::io_context& IoContext, HttpClientSettings Settings = {})
+ {
+ return AsyncHttpClient(fmt::format("127.0.0.1:{}", Port), IoContext, Settings);
+ }
+};
+
+//////////////////////////////////////////////////////////////////////////
+// Tests
+
+TEST_SUITE_BEGIN("http.asynchttpclient");
+
+TEST_CASE("asynchttpclient.future.verbs")
+{
+ AsyncTestServerFixture Fixture;
+ AsyncHttpClient Client = Fixture.MakeClient();
+
+ SUBCASE("GET returns 200 with expected body")
+ {
+ auto Future = Client.Get("/api/async-test/echo/method");
+ auto Resp = Future.get();
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "GET");
+ }
+
+ SUBCASE("POST dispatches correctly")
+ {
+ auto Future = Client.Post("/api/async-test/echo/method");
+ auto Resp = Future.get();
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "POST");
+ }
+
+ SUBCASE("PUT dispatches correctly")
+ {
+ auto Future = Client.Put("/api/async-test/echo/method");
+ auto Resp = Future.get();
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "PUT");
+ }
+
+ SUBCASE("DELETE dispatches correctly")
+ {
+ auto Future = Client.Delete("/api/async-test/echo/method");
+ auto Resp = Future.get();
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "DELETE");
+ }
+
+ SUBCASE("HEAD returns 200 with empty body")
+ {
+ auto Future = Client.Head("/api/async-test/echo/method");
+ auto Resp = Future.get();
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), ""sv);
+ }
+}
+
+TEST_CASE("asynchttpclient.future.get")
+{
+ AsyncTestServerFixture Fixture;
+ AsyncHttpClient Client = Fixture.MakeClient();
+
+ SUBCASE("simple GET with text response")
+ {
+ auto Future = Client.Get("/api/async-test/hello");
+ auto Resp = Future.get();
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.StatusCode, HttpResponseCode::OK);
+ CHECK_EQ(Resp.AsText(), "hello world");
+ }
+
+ SUBCASE("GET returning JSON")
+ {
+ auto Future = Client.Get("/api/async-test/json");
+ auto Resp = Future.get();
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "{\"ok\":true}");
+ }
+
+ SUBCASE("GET 204 NoContent")
+ {
+ auto Future = Client.Get("/api/async-test/nocontent");
+ auto Resp = Future.get();
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.StatusCode, HttpResponseCode::NoContent);
+ }
+}
+
+TEST_CASE("asynchttpclient.future.post.with.payload")
+{
+ AsyncTestServerFixture Fixture;
+ AsyncHttpClient Client = Fixture.MakeClient();
+
+ std::string_view PayloadStr = "async payload data";
+ IoBuffer Payload(IoBuffer::Clone, PayloadStr.data(), PayloadStr.size());
+ Payload.SetContentType(ZenContentType::kText);
+
+ auto Future = Client.Post("/api/async-test/echo", Payload);
+ auto Resp = Future.get();
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "async payload data");
+}
+
+TEST_CASE("asynchttpclient.future.put.with.payload")
+{
+ AsyncTestServerFixture Fixture;
+ AsyncHttpClient Client = Fixture.MakeClient();
+
+ std::string_view PutStr = "put payload";
+ IoBuffer Payload(IoBuffer::Clone, PutStr.data(), PutStr.size());
+ Payload.SetContentType(ZenContentType::kText);
+
+ auto Future = Client.Put("/api/async-test/echo", Payload);
+ auto Resp = Future.get();
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "put payload");
+}
+
+TEST_CASE("asynchttpclient.callback")
+{
+ AsyncTestServerFixture Fixture;
+ AsyncHttpClient Client = Fixture.MakeClient();
+
+ std::promise<HttpClient::Response> Promise;
+ auto Future = Promise.get_future();
+
+ Client.AsyncGet("/api/async-test/hello", [&Promise](HttpClient::Response Resp) { Promise.set_value(std::move(Resp)); });
+
+ auto Resp = Future.get();
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "hello world");
+}
+
+TEST_CASE("asynchttpclient.concurrent.requests")
+{
+ AsyncTestServerFixture Fixture;
+ AsyncHttpClient Client = Fixture.MakeClient();
+
+ // Fire multiple requests concurrently
+ auto Future1 = Client.Get("/api/async-test/hello");
+ auto Future2 = Client.Get("/api/async-test/json");
+ auto Future3 = Client.Post("/api/async-test/echo/method");
+ auto Future4 = Client.Delete("/api/async-test/echo/method");
+
+ auto Resp1 = Future1.get();
+ auto Resp2 = Future2.get();
+ auto Resp3 = Future3.get();
+ auto Resp4 = Future4.get();
+
+ CHECK(Resp1.IsSuccess());
+ CHECK_EQ(Resp1.AsText(), "hello world");
+
+ CHECK(Resp2.IsSuccess());
+ CHECK_EQ(Resp2.AsText(), "{\"ok\":true}");
+
+ CHECK(Resp3.IsSuccess());
+ CHECK_EQ(Resp3.AsText(), "POST");
+
+ CHECK(Resp4.IsSuccess());
+ CHECK_EQ(Resp4.AsText(), "DELETE");
+}
+
+TEST_CASE("asynchttpclient.external.io_context")
+{
+ AsyncTestServerFixture Fixture;
+
+ asio::io_context IoContext;
+ auto WorkGuard = asio::make_work_guard(IoContext);
+ std::thread IoThread([&IoContext]() { IoContext.run(); });
+
+ {
+ AsyncHttpClient Client = Fixture.MakeClient(IoContext);
+
+ auto Future = Client.Get("/api/async-test/hello");
+ auto Resp = Future.get();
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "hello world");
+ }
+
+ WorkGuard.reset();
+ IoThread.join();
+}
+
+TEST_CASE("asynchttpclient.connection.error")
+{
+ // Connect to a port where nothing is listening
+ AsyncHttpClient Client("127.0.0.1:1", HttpClientSettings{.ConnectTimeout = std::chrono::milliseconds(500)});
+
+ auto Future = Client.Get("/should-fail");
+ auto Resp = Future.get();
+
+ CHECK_FALSE(Resp.IsSuccess());
+ CHECK(Resp.Error.has_value());
+ CHECK(Resp.Error->IsConnectionError());
+}
+
+TEST_SUITE_END();
+
+void
+asynchttpclient_test_forcelink()
+{
+}
+
+} // namespace zen
+
+#endif