diff --git a/include/anyhttp/client.hpp b/include/anyhttp/client.hpp index dbb6cb7..1a3f20f 100644 --- a/include/anyhttp/client.hpp +++ b/include/anyhttp/client.hpp @@ -45,6 +45,9 @@ class Response public: int status_code() const noexcept; + /// The response header fields, without HTTP/2 and HTTP/3 pseudo-headers. + const Fields& fields() const; + public: /** * Reads a part of the response body. diff --git a/include/anyhttp/client_impl.hpp b/include/anyhttp/client_impl.hpp index 7529343..9e06014 100644 --- a/include/anyhttp/client_impl.hpp +++ b/include/anyhttp/client_impl.hpp @@ -32,6 +32,7 @@ class Response::Impl : public impl::Reader virtual unsigned int status_code() const noexcept = 0; virtual boost::url_view url() const = 0; + virtual const Fields& fields() const = 0; using ReaderOrWriter = impl::Reader; }; diff --git a/include/anyhttp/detail/h2_session_details.hpp b/include/anyhttp/detail/h2_session_details.hpp index d76a32d..b91a23d 100644 --- a/include/anyhttp/detail/h2_session_details.hpp +++ b/include/anyhttp/detail/h2_session_details.hpp @@ -246,6 +246,7 @@ awaitable ServerSession::do_session(Buffer&& buffer) auto stream = this->create_stream(1); stream->method = std::move(m_upgrade->method); stream->url = std::move(m_upgrade->url); + stream->fields = std::move(m_upgrade->fields); mlogd("upgraded from HTTP/1.1: {} {}", stream->method, stream->url.buffer()); stream->on_request(); stream->on_eof(session, 1); diff --git a/include/anyhttp/h2_backend.hpp b/include/anyhttp/h2_backend.hpp index d8fe6c3..fd73871 100644 --- a/include/anyhttp/h2_backend.hpp +++ b/include/anyhttp/h2_backend.hpp @@ -37,6 +37,7 @@ struct Upgrade std::string settings; ///< decoded payload of the HTTP2-Settings header std::string method; boost::urls::url url; + Fields fields; ///< request headers, without the connection-specific ones }; std::shared_ptr make_server_session(server::Server::Impl& server, diff --git a/include/anyhttp/h2_stream.hpp b/include/anyhttp/h2_stream.hpp index 4e56329..1382ac4 100644 --- a/include/anyhttp/h2_stream.hpp +++ b/include/anyhttp/h2_stream.hpp @@ -43,6 +43,7 @@ class NGHttp2Reader : public Interface unsigned int status_code() const noexcept override; boost::url_view url() const override; + const Fields& fields() const override; NGHttp2Stream* stream; asio::any_io_executor executor; // kept as a copy so a detached reader can still complete @@ -179,6 +180,7 @@ class NGHttp2Stream : public std::enable_shared_from_this std::vector> received_headers; std::optional status_code; std::optional content_length; + Fields fields; // all received headers except the pseudo-headers bool closed = false; // set to true after on_stream_close_callback diff --git a/include/anyhttp/h3_stream.hpp b/include/anyhttp/h3_stream.hpp index c963ee0..99e9735 100644 --- a/include/anyhttp/h3_stream.hpp +++ b/include/anyhttp/h3_stream.hpp @@ -263,6 +263,12 @@ class Http3Reader : public Interface return stream->url; } + const Fields& fields() const override + { + assert(stream); + return stream->fields; + } + void async_read_some(asio::mutable_buffer buffer, ReadSomeHandler&& handler) override { // diff --git a/include/anyhttp/server.hpp b/include/anyhttp/server.hpp index 280653e..93f9f89 100644 --- a/include/anyhttp/server.hpp +++ b/include/anyhttp/server.hpp @@ -68,6 +68,9 @@ class Request boost::url_view url() const; std::optional content_length() const noexcept; + /// The request header fields, without HTTP/2 and HTTP/3 pseudo-headers. + const Fields& fields() const; + /** * Looks up a query parameter and converts its value to \c T. * diff --git a/include/anyhttp/server_impl.hpp b/include/anyhttp/server_impl.hpp index c546012..60608c7 100644 --- a/include/anyhttp/server_impl.hpp +++ b/include/anyhttp/server_impl.hpp @@ -27,6 +27,7 @@ class Request::Impl : public impl::Reader // FIXME: doesn't make sense to have a status_code() for a server request, but keeps beast happy virtual unsigned int status_code() const noexcept = 0; virtual boost::url_view url() const = 0; + virtual const Fields& fields() const = 0; using ReaderOrWriter = impl::Reader; }; diff --git a/src/client.cpp b/src/client.cpp index 33f7200..a647ad7 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -85,6 +85,7 @@ Response::~Response() { reset(); } // ------------------------------------------------------------------------------------------------- int Response::status_code() const noexcept { return impl->status_code(); } +const Fields& Response::fields() const { return impl->fields(); } void Response::async_read_some_any(boost::asio::mutable_buffer buffer, ReadSomeHandler&& handler) { diff --git a/src/h1_session.cpp b/src/h1_session.cpp index 6c0a2db..e5bf31a 100644 --- a/src/h1_session.cpp +++ b/src/h1_session.cpp @@ -117,6 +117,7 @@ class BeastReader : public Interface return parser.get().result_int(); } boost::url_view url() const override { return m_url; } + const Fields& fields() const override { return parser.get(); } std::optional content_length() const noexcept override { if (parser.content_length()) @@ -739,6 +740,26 @@ static std::optional h2c_upgrade(const http::requestcontent_length.emplace(); std::from_chars(value.begin(), value.end(), *stream->content_length); } + + if (!name.starts_with(':')) + stream->fields.insert(name, value); } catch (std::exception& ex) { diff --git a/src/h2_stream.cpp b/src/h2_stream.cpp index 15dce01..2e790ad 100644 --- a/src/h2_stream.cpp +++ b/src/h2_stream.cpp @@ -94,6 +94,13 @@ boost::url_view NGHttp2Reader::url() const return {stream->url}; } +template +const Fields& NGHttp2Reader::fields() const +{ + assert(stream); + return stream->fields; +} + template std::optional NGHttp2Reader::content_length() const noexcept { diff --git a/src/h3_stream.cpp b/src/h3_stream.cpp index 96085d7..db43f81 100644 --- a/src/h3_stream.cpp +++ b/src/h3_stream.cpp @@ -622,15 +622,19 @@ void Http3Stream::on_header(std::string_view name, std::string_view value) try { if (name.starts_with(':')) + { on_pseudo_header(name, value); - else if (name == "content-length") + return; + } + + if (name == "content-length") { size_t len = 0; if (std::from_chars(value.begin(), value.end(), len).ec == std::errc{}) content_length = len; } - else - fields.set(name, value); + + fields.insert(name, value); // insert, not set: repeated fields must all be kept } catch (const std::exception& ex) { diff --git a/src/request_handlers.cpp b/src/request_handlers.cpp index 96ff995..6db0e1b 100644 --- a/src/request_handlers.cpp +++ b/src/request_handlers.cpp @@ -72,6 +72,10 @@ awaitable dump(server::Request request, server::Response response) std::println(str, " {}={} ({})", key, EscapedString(value), _); std::println(str, "fragment: {} ({})", url.fragment(), url.encoded_fragment()); + std::println(str, "headers:"); + for (const auto& field : request.fields()) + std::println(str, " {}: {}", field.name_string(), EscapedString(field.value())); + auto body = str.str(); co_await response.async_submit( 200, fields({{"Content-Length", body.size()}, {"Content-Type", "text/plain"}})); diff --git a/src/server.cpp b/src/server.cpp index b1372f7..3684522 100644 --- a/src/server.cpp +++ b/src/server.cpp @@ -45,6 +45,12 @@ std::optional Request::content_length() const noexcept return impl->content_length(); } +const Fields& Request::fields() const +{ + assert(impl); + return impl->fields(); +} + void Request::async_read_some_any(asio::mutable_buffer buffer, ReadSomeHandler&& handler) { assert(impl); diff --git a/test/test_client_async.cpp b/test/test_client_async.cpp index ab9e0cc..aab2c27 100644 --- a/test/test_client_async.cpp +++ b/test/test_client_async.cpp @@ -9,11 +9,13 @@ #include #include +using namespace testing; + // ================================================================================================= INSTANTIATE_TEST_SUITE_P(ClientAsync, ClientAsync, - ::testing::Values(anyhttp::Protocol::http11, anyhttp::Protocol::h2, - anyhttp::Protocol::h3), + Values(anyhttp::Protocol::http11, anyhttp::Protocol::h2, + anyhttp::Protocol::h3), NameGenerator); // ------------------------------------------------------------------------------------------------- @@ -761,8 +763,8 @@ TEST_P(ClientAsync, Dump) co_await send_eof(request); auto response = co_await request.async_get_response(); auto dump = co_await read(response); - EXPECT_THAT(dump, testing::HasSubstr("path: /dump space")); - EXPECT_THAT(dump, testing::HasSubstr(" blah=white space")); + EXPECT_THAT(dump, HasSubstr("path: /dump space")); + EXPECT_THAT(dump, HasSubstr(" blah=white space")); }; } diff --git a/test/test_client_async_cancellation.cpp b/test/test_client_async_cancellation.cpp index d1ccc3e..99c5d1c 100644 --- a/test/test_client_async_cancellation.cpp +++ b/test/test_client_async_cancellation.cpp @@ -4,6 +4,8 @@ #include #include +using namespace testing; + // ================================================================================================= // @@ -14,8 +16,8 @@ class ClientAsyncCancellation : public ClientAsync }; INSTANTIATE_TEST_SUITE_P(ClientAsyncCancellation, ClientAsyncCancellation, - ::testing::Values(anyhttp::Protocol::http11, anyhttp::Protocol::h2, - anyhttp::Protocol::h3), + Values(anyhttp::Protocol::http11, anyhttp::Protocol::h2, + anyhttp::Protocol::h3), NameGenerator); // ------------------------------------------------------------------------------------------------- @@ -233,8 +235,8 @@ TEST_P(ClientAsyncCancellation, WHEN_send_more_than_content_length_THEN_connecti // ECONNRESET, any later one with EPIPE. Single-threaded we reliably hit the former, with // more than one thread the latter; both mean the same thing here. // - EXPECT_THAT(code(ep), testing::AnyOf(boost::system::errc::connection_reset, - boost::system::errc::broken_pipe)); + EXPECT_THAT(code(ep), + AnyOf(boost::system::errc::connection_reset, boost::system::errc::broken_pipe)); }; } diff --git a/test/test_client_connect.cpp b/test/test_client_connect.cpp index 19d21de..933baa0 100644 --- a/test/test_client_connect.cpp +++ b/test/test_client_connect.cpp @@ -1,8 +1,10 @@ #include "test_fixtures.hpp" +using namespace testing; + // ================================================================================================= -class ClientConnect : public testing::Test +class ClientConnect : public Test { public: void SetUp() override { setupLogging(); } diff --git a/test/test_external.cpp b/test/test_external.cpp index a3195fe..2fd04c9 100644 --- a/test/test_external.cpp +++ b/test/test_external.cpp @@ -423,7 +423,7 @@ TEST_F(ExternalCustom, curl_h2c_upgrade) // clang-format off Args args = {"-sS", "-v", "--http2", "-w", "%{http_code} HTTP/%{http_version}\n", - url + "?first", url + "?second"}; + url + "?first", "-H", "x-custom:value", url + "?second"}; // clang-format on auto future = spawn(CURL_PATH, std::move(args)); run(); @@ -432,6 +432,14 @@ TEST_F(ExternalCustom, curl_h2c_upgrade) EXPECT_THAT(output, testing::HasSubstr("query: first")); EXPECT_THAT(output, testing::HasSubstr("query: second")); + // the header goes along with both requests, the upgraded one included + std::string_view headers = output; + size_t with_header = 0; + for (size_t pos; (pos = headers.find("\n x-custom: value\n")) != std::string_view::npos; + ++with_header) + headers.remove_prefix(pos + 1); + EXPECT_EQ(with_header, 2) << output; + std::string_view rest = output; size_t upgraded = 0; for (size_t pos; (pos = rest.find("200 HTTP/2\n")) != std::string_view::npos; ++upgraded) diff --git a/test/test_file_handler.cpp b/test/test_file_handler.cpp index d6925c6..7d085ec 100644 --- a/test/test_file_handler.cpp +++ b/test/test_file_handler.cpp @@ -5,6 +5,8 @@ #include #include +using namespace testing; + // ================================================================================================= // @@ -86,8 +88,8 @@ class FileHandler : public ClientAsync // ------------------------------------------------------------------------------------------------- INSTANTIATE_TEST_SUITE_P(FileHandler, FileHandler, - ::testing::Values(anyhttp::Protocol::http11, anyhttp::Protocol::h2, - anyhttp::Protocol::h3), + Values(anyhttp::Protocol::http11, anyhttp::Protocol::h2, + anyhttp::Protocol::h3), NameGenerator); // ================================================================================================= diff --git a/test/test_h2c_upgrade.cpp b/test/test_h2c_upgrade.cpp index dd4ba3f..6e7f02c 100644 --- a/test/test_h2c_upgrade.cpp +++ b/test/test_h2c_upgrade.cpp @@ -14,6 +14,8 @@ #include #include +using namespace testing; + // ================================================================================================= // @@ -54,7 +56,9 @@ class H2CUpgrade : public Server } /// Upgrades a GET for the first target and sends GETs for the others as HTTP/2 streams. - awaitable upgrade(std::vector targets) + /// \p fields go along with the upgrade request. + awaitable upgrade(std::vector targets, + boost::beast::http::fields fields = {}) { namespace http = boost::beast::http; @@ -111,6 +115,8 @@ class H2CUpgrade : public Server EXPECT_GT(len, 0); http::request request{http::verb::get, targets.front(), 11}; + for (const auto& field : fields) + request.insert(field.name_string(), field.value()); request.set(http::field::host, authority); request.set(http::field::connection, "Upgrade, HTTP2-Settings"); request.set(http::field::upgrade, "h2c"); @@ -246,8 +252,28 @@ TEST_F(H2CUpgrade, WHEN_upgrade_is_requested_THEN_request_continues_as_stream_1) ASSERT_TRUE(responses.contains(1)); EXPECT_EQ(responses[1].status, 200); EXPECT_TRUE(responses[1].closed); - EXPECT_THAT(responses[1].body, testing::HasSubstr("path: /dump")); - EXPECT_THAT(responses[1].body, testing::HasSubstr("query: first")); + EXPECT_THAT(responses[1].body, HasSubstr("path: /dump")); + EXPECT_THAT(responses[1].body, HasSubstr("query: first")); +} + +TEST_F(H2CUpgrade, WHEN_upgraded_THEN_request_headers_are_passed_on_to_stream_1) +{ + boost::beast::http::fields fields; + fields.set("x-custom", "value"); + fields.set(boost::beast::http::field::keep_alive, "timeout=5"); + auto responses = run(upgrade({"/dump?first"}, std::move(fields))); + + ASSERT_TRUE(responses.contains(1)); + EXPECT_EQ(responses[1].status, 200); + const auto& body = responses[1].body; + EXPECT_THAT(body, HasSubstr("\n x-custom: value\n")); + EXPECT_THAT(body, HasSubstr("\n Host: 127.0.0.2:")); + + // connection-specific fields do not exist in HTTP/2 (RFC 9113, section 8.2.2) + EXPECT_THAT(body, Not(HasSubstr("Connection:"))); + EXPECT_THAT(body, Not(HasSubstr("Upgrade:"))); + EXPECT_THAT(body, Not(HasSubstr("HTTP2-Settings:"))); + EXPECT_THAT(body, Not(HasSubstr("Keep-Alive:"))); } TEST_F(H2CUpgrade, WHEN_upgraded_THEN_connection_takes_more_streams) @@ -256,9 +282,9 @@ TEST_F(H2CUpgrade, WHEN_upgraded_THEN_connection_takes_more_streams) ASSERT_EQ(responses.size(), 3); EXPECT_EQ(responses[1].status, 200); - EXPECT_THAT(responses[1].body, testing::HasSubstr("query: first")); + EXPECT_THAT(responses[1].body, HasSubstr("query: first")); EXPECT_EQ(responses[3].status, 200); - EXPECT_THAT(responses[3].body, testing::HasSubstr("query: second")); + EXPECT_THAT(responses[3].body, HasSubstr("query: second")); EXPECT_EQ(responses[5].status, 404); } @@ -279,7 +305,7 @@ TEST_F(H2CUpgrade, WHEN_http2_settings_are_missing_THEN_is_served_as_http11) auto response = run(http11(std::move(request))); EXPECT_EQ(response.result_int(), 200); - EXPECT_THAT(response.body(), testing::HasSubstr("query: no-settings")); + EXPECT_THAT(response.body(), HasSubstr("query: no-settings")); } TEST_F(H2CUpgrade, WHEN_http2_settings_are_invalid_THEN_is_served_as_http11) @@ -289,7 +315,7 @@ TEST_F(H2CUpgrade, WHEN_http2_settings_are_invalid_THEN_is_served_as_http11) auto response = run(http11(std::move(request))); EXPECT_EQ(response.result_int(), 200); - EXPECT_THAT(response.body(), testing::HasSubstr("query: invalid")); + EXPECT_THAT(response.body(), HasSubstr("query: invalid")); } // ================================================================================================= diff --git a/test/test_server.cpp b/test/test_server.cpp index 6a0828d..c7d0360 100644 --- a/test/test_server.cpp +++ b/test/test_server.cpp @@ -6,10 +6,11 @@ #include #include +using namespace testing; + // ================================================================================================= -INSTANTIATE_TEST_SUITE_P(Server, Server, - ::testing::Values(anyhttp::Protocol::http11, anyhttp::Protocol::h2), +INSTANTIATE_TEST_SUITE_P(Server, Server, Values(anyhttp::Protocol::http11, anyhttp::Protocol::h2), NameGenerator); // ------------------------------------------------------------------------------------------------- @@ -39,7 +40,7 @@ TEST_P(Server, Stop) // Note that this is *not* what a client calling Session::reset() looks like: that one says // goodbye, and the server cleans up right away by way of the draining period. // -class Http3IdleTimeout : public testing::Test +class Http3IdleTimeout : public Test { protected: static constexpr auto IdleTimeout = 500ms;