diff options
| author | Stefan Boberg <[email protected]> | 2026-04-09 11:02:41 +0200 |
|---|---|---|
| committer | GitHub Enterprise <[email protected]> | 2026-04-09 11:02:41 +0200 |
| commit | 5900f6a6d892fbe582c46063cc399a840e60ef2e (patch) | |
| tree | 76735ff6de39c2c515a866ecc9d7b4309d63669d /src/zenhttp/asynchttpclient_test.cpp | |
| parent | migrate from http_parser to llhttp (#929) (diff) | |
| download | zen-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.cpp | 315 |
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 |