diff --git a/.cspell.json b/.cspell.json index 3b7b7f4..4bfb9b5 100644 --- a/.cspell.json +++ b/.cspell.json @@ -49,6 +49,7 @@ "respawn", "RESPAWNED", "respawning", + "rvalues", "scid", "SCIDLEN", "scids", diff --git a/README.md b/README.md index db1fa75..352aeba 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,21 @@ awaitable do_session(Client& client, boost::urls::url url) auto response = co_await request.async_get_response(); } ``` + +A plain GET needs none of those four steps spelled out. `async_get()` does the whole request as one +operation and hands back the response as a plain Beast message -- status, fields and the body as a +`std::string`: + +```c++ + auto session = co_await client.async_connect(); + auto message = co_await session.async_get(url); + std::println("{}: {} bytes", message.result_int(), message.body().size()); +``` + +The convenience is paid for with memory, as the body is buffered in full: anything that wants to +look at the body while it arrives, or to send a body of its own, still goes through +`async_submit()`. + # Implementation The asynchronous operations exposed by server and client are [ASIO asynchronous operations](https://think-async.com/Asio/asio-1.30.2/doc/asio/reference/asynchronous_operations.html). As such, they support a range of [completion tokens](https://think-async.com/Asio/asio-1.30.2/doc/asio/overview/model/completion_tokens.html) like [use_awaitable](https://think-async.com/Asio/asio-1.30.2/doc/asio/reference/use_awaitable.html) or plain callbacks. diff --git a/include/anyhttp/any_async_stream.hpp b/include/anyhttp/any_async_stream.hpp deleted file mode 100644 index 6b56faf..0000000 --- a/include/anyhttp/any_async_stream.hpp +++ /dev/null @@ -1,142 +0,0 @@ -#pragma once - -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include -#include - -namespace asio = boost::asio; -namespace ip = asio::ip; - -namespace anyhttp -{ -// ================================================================================================= - -using ReadWrite = void(boost::system::error_code, std::size_t); -using ReadWriteHandler = asio::any_completion_handler; - -// using ConstBufferVector = boost::container::small_vector; -// using MutableBufferVector = boost::container::small_vector; -using ConstBufferVector = const_buffer_array<16>; -using MutableBufferVector = mutable_buffer_array<16>; - -using Shutdown = void(boost::system::error_code); -using ShutdownHandler = asio::any_completion_handler; - -/** - * Attempt to create a type-erased async stream. - * - * The difficult part here is to type-erase the buffer sequences. For starters, the buffers are - * copied into a small vector that can hold up to 4 buffers without allocation. This seems to work - * reasonably well. - * - * There is also asio::buffer_sequence_adapter and linearise(), which seems to be used in ASIO's - * SSL code was well. It merges a set of buffers into a new, contiguous buffer. But that is slow. - */ -class AnyAsyncStream -{ -public: - using executor_type = boost::asio::any_io_executor; - - class Impl - { - public: - using executor_type = boost::asio::any_io_executor; - virtual ~Impl() = default; - virtual executor_type get_executor() noexcept = 0; - virtual ip::tcp::socket& get_socket() = 0; - - using ConstBuffers = ConstBufferVector; - using MutableBuffers = MutableBufferVector; - virtual void async_write_some(ReadWriteHandler handler, ConstBufferVector buffer) = 0; - virtual void async_read_some(ReadWriteHandler handler, MutableBufferVector buffer) = 0; - - virtual void async_shutdown_impl(ShutdownHandler handler) - { - auto ex = boost::asio::get_associated_immediate_executor(handler, get_executor()); - ex.execute([handler = std::move(handler)]() mutable { // - handler(boost::system::error_code()); - }); - } - }; - -protected: - std::unique_ptr impl; - -public: - AnyAsyncStream(std::unique_ptr impl_) : impl(std::move(impl_)) {} - - inline executor_type get_executor() noexcept { return impl->get_executor(); } - inline ip::tcp::socket& get_socket() { return impl->get_socket(); } - - // - // async_write_some - // - // The async operations of ASIO are designed to work with sequences of buffers. Those cannot - // easily be type-erased, aside transforming them to a vector. - // - // The requirements for ConstBufferSequence are defined here: - // https://live.boost.org/doc/libs/1_88_0/doc/html/boost_asio/reference/ConstBufferSequence.html - // - // The iterators returned by asio::buffer_sequence_{begin,end} must be 'bidirectional', but are - // not required to be 'contiguous'. So those iterators cannot be simply convereted to a span. - // - // * https://en.cppreference.com/w/cpp/iterator/bidirectional_iterator - // * https://en.cppreference.com/w/cpp/iterator/contiguous_iterator.html - // - // In the end, we just re-use ASIOs "buffer sequence adapter" that yields a single buffer. - // When writing, it merges small buffers and when reading, it uses the first non-empty buffer. - // This is simple, but effective -- and also what an SSL stream does, internally. - // - template > - requires boost::beast::is_const_buffer_sequence::value - auto async_write_some(const ConstBufferSequence& buffers, - CompletionToken&& token = CompletionToken()) - { - return boost::asio::async_initiate( - [this](ReadWriteHandler handler, const ConstBufferSequence& buffers) - { - impl->async_write_some(std::move(handler), - ConstBufferVector{asio::buffer_sequence_begin(buffers), - asio::buffer_sequence_end(buffers)}); - }, token, buffers); - } - - // - // async_read_some - // - template > - requires boost::beast::is_mutable_buffer_sequence::value - auto async_read_some(const MutableBufferSequence& buffers, - CompletionToken&& token = CompletionToken()) - { - return boost::asio::async_initiate( - [this](ReadWriteHandler handler, const MutableBufferSequence& buffers) - { - impl->async_read_some(std::move(handler), - MutableBufferVector{asio::buffer_sequence_begin(buffers), - asio::buffer_sequence_end(buffers)}); - }, token, buffers); - } -}; - -static_assert(boost::beast::is_async_stream::value); - -// ================================================================================================= - -} // namespace anyhttp diff --git a/include/anyhttp/buffer_array.hpp b/include/anyhttp/buffer_array.hpp index f17a61a..b82f0df 100644 --- a/include/anyhttp/buffer_array.hpp +++ b/include/anyhttp/buffer_array.hpp @@ -106,7 +106,7 @@ class buffer_array buffer_array(BS const& bs) noexcept : dummy_(0) { auto it = buffer_sequence_begin(bs); - auto const last = buffer_sequence_begin(bs); + auto const last = buffer_sequence_end(bs); while (it != last && n_ < N) { value_type b(*it); diff --git a/include/anyhttp/client.hpp b/include/anyhttp/client.hpp index c8d0e19..4d20c85 100644 --- a/include/anyhttp/client.hpp +++ b/include/anyhttp/client.hpp @@ -6,6 +6,8 @@ #include #include +#include +#include #include @@ -34,6 +36,17 @@ struct Config // ================================================================================================= +// +// A response received in one piece: status, header fields and the whole body as a string. +// +// This is a plain Beast message, with nothing of anyhttp left in it -- what \c Session::async_get() +// hands back. Its version is 11 whatever the protocol was: HTTP/2 and HTTP/3 have no version on +// the wire, and a Beast message has nowhere else to put one. +// +using Message = boost::beast::http::response; + +// ------------------------------------------------------------------------------------------------- + class Response { public: diff --git a/include/anyhttp/concepts.hpp b/include/anyhttp/concepts.hpp index 419b9a8..e93c45c 100644 --- a/include/anyhttp/concepts.hpp +++ b/include/anyhttp/concepts.hpp @@ -16,8 +16,8 @@ concept MutableBufferSequence = boost::asio::is_mutable_buffer_sequence::valu // -// https://think-async.com/Asio/asio-1.11.0/doc/asio/reference/AsyncReadStream.html -// https://think-async.com/Asio/asio-1.11.0/doc/asio/reference/AsyncWriteStream.html +// https://think-async.com/Asio/asio-1.38.2/doc/asio/reference/AsyncReadStream.html +// https://think-async.com/Asio/asio-1.38.2/doc/asio/reference/AsyncWriteStream.html // template diff --git a/include/anyhttp/detail/any_async_stream.hpp b/include/anyhttp/detail/any_async_stream.hpp new file mode 100644 index 0000000..36ed4b2 --- /dev/null +++ b/include/anyhttp/detail/any_async_stream.hpp @@ -0,0 +1,162 @@ +#pragma once + +// +// The type-erased async stream as its users see it. The implementation behind it is only forward +// declared here: it lives in anyhttp/detail/any_async_stream_impl.hpp, which src/ +// any_async_stream_impl.cpp is the only place to include -- and to instantiate. +// + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +namespace asio = boost::asio; +namespace ip = asio::ip; + +namespace anyhttp +{ + +// ================================================================================================= + +using ReadWrite = void(boost::system::error_code, std::size_t); +using ReadWriteHandler = asio::any_completion_handler; + +using ConstBufferVector = const_buffer_array<16>; +using MutableBufferVector = mutable_buffer_array<16>; + +using Shutdown = void(boost::system::error_code); +using ShutdownHandler = asio::any_completion_handler; + +/** + * The socket underneath a stream, as far as the sessions care about it: enough for shutdown() and + * close(), which is all they do with it. A TLS stream hands out its \c lowest_layer(), which is + * this rather than the full ip::tcp::socket, so this is what the type-erased stream offers, too. + */ +using TcpSocketBase = asio::basic_socket; + +/// The TLS stream the server and client run on, spelled out often enough to deserve a name. +using SslStream = asio::ssl::stream; + +/** + * Attempt to create a type-erased async stream with ASIO. + * + * The difficult part here is to type-erase the buffer sequences. The buffers are copied into a + * buffer_array, a fixed-capacity, non-allocating array of buffer descriptors that is itself a + * buffer sequence. This seems to work reasonably well. + * + * There is also \c asio::buffer_sequence_adapter and \c linearise(), which seem to be used in ASIO + * SSL code as well. It merges a set of buffers into a new, contiguous buffer. But that is slow. + */ +class any_async_stream +{ +public: + using executor_type = boost::asio::any_io_executor; + + /// The type-erased stream itself, defined in anyhttp/detail/any_async_stream_impl.hpp. + class Impl; + + explicit any_async_stream(std::unique_ptr impl); + any_async_stream(any_async_stream&&) noexcept; + any_async_stream& operator=(any_async_stream&&) noexcept; + ~any_async_stream(); + + executor_type get_executor() noexcept; + TcpSocketBase& get_socket(); + + // + // async_write_some + // + // The async operations of ASIO are designed to work with sequences of buffers. Those cannot + // easily be type-erased, so we copy the buffer descriptors into a fixed-capacity array. + // + // The requirements for ConstBufferSequence are defined here: + // https://live.boost.org/doc/libs/1_88_0/doc/html/boost_asio/reference/ConstBufferSequence.html + // + // The iterators returned by asio::buffer_sequence_{begin,end} must be 'bidirectional', but are + // not required to be 'contiguous'. So those iterators cannot be simply converted to a span. + // + // * https://en.cppreference.com/w/cpp/iterator/bidirectional_iterator + // * https://en.cppreference.com/w/cpp/iterator/contiguous_iterator.html + // + // Instead, we copy them into a buffer_array, which is itself a (contiguous) buffer sequence and + // can be passed on to the underlying stream unchanged. Nothing is merged or linearized, so + // scatter/gather I/O is preserved. Empty buffers are dropped while copying, and sequences longer + // than the array's capacity are truncated -- which is harmless for a "some" operation, as it + // just results in a shorter transfer. + // + template > + requires boost::beast::is_const_buffer_sequence::value + auto async_write_some(const ConstBufferSequence& buffers, + CompletionToken&& token = CompletionToken()) + { + return boost::asio::async_initiate( + [this](ReadWriteHandler handler, ConstBufferVector buffers) + { // + write_some(std::move(handler), std::move(buffers)); + }, token, ConstBufferVector{buffers}); + } + + // + // async_read_some + // + template > + requires boost::beast::is_mutable_buffer_sequence::value + auto async_read_some(const MutableBufferSequence& buffers, + CompletionToken&& token = CompletionToken()) + { + return boost::asio::async_initiate( + [this](ReadWriteHandler handler, MutableBufferVector buffers) + { // + read_some(std::move(handler), std::move(buffers)); + }, token, MutableBufferVector{buffers}); + } + +private: + // + // The initiations, with the buffer sequence already type-erased. Out of line, because this is + // where the implementation is dereferenced -- it is incomplete here. + // + void write_some(ReadWriteHandler handler, ConstBufferVector buffers); + void read_some(ReadWriteHandler handler, MutableBufferVector buffers); + + std::unique_ptr impl; +}; + +static_assert(boost::beast::is_async_stream::value); + +// ------------------------------------------------------------------------------------------------- + +// +// The stream is moved into the type-erasing wrapper -- hence the rvalue reference, which the +// constraint keeps from matching an lvalue, just like the session factories do it. +// +// This is defined in anyhttp/detail/any_async_stream_impl.hpp and explicitly instantiated in +// src/any_async_stream_impl.cpp for each of the stream types below, so that the implementation +// is instantiated in that one place only. +// + +template + requires(!std::is_reference_v) +any_async_stream make_any_async_stream(Stream&& stream); + +extern template any_async_stream make_any_async_stream(ip::tcp::socket&&); +extern template any_async_stream make_any_async_stream(SslStream&&); + +// ================================================================================================= + +} // namespace anyhttp diff --git a/include/anyhttp/detail/any_async_stream_impl.hpp b/include/anyhttp/detail/any_async_stream_impl.hpp new file mode 100644 index 0000000..0434afc --- /dev/null +++ b/include/anyhttp/detail/any_async_stream_impl.hpp @@ -0,0 +1,93 @@ +#pragma once + +// +// Definition of the implementation behind anyhttp::any_async_stream, instantiated only by +// src/any_async_stream_impl.cpp -- make_any_async_stream() is what everyone else uses instead. +// + +#include "anyhttp/detail/any_async_stream.hpp" +#include "anyhttp/stream_traits.hpp" + +#include + +#include + +namespace anyhttp +{ + +// ================================================================================================= + +/** + * The type-erased stream, with the buffer sequences of the async operations erased as well: they + * arrive as a \c buffer_array, which is a buffer sequence itself and can be handed to the + * underlying stream unchanged. + */ +class any_async_stream::Impl +{ +public: + virtual ~Impl() = default; + + using executor_type = boost::asio::any_io_executor; + virtual executor_type get_executor() noexcept = 0; + virtual TcpSocketBase& get_socket() = 0; + + virtual void async_write_some(ReadWriteHandler handler, ConstBufferVector buffer) = 0; + virtual void async_read_some(ReadWriteHandler handler, MutableBufferVector buffer) = 0; + virtual void async_shutdown_impl(ShutdownHandler handler) + { + auto ex = boost::asio::get_associated_immediate_executor(handler, get_executor()); + ex.execute([handler = std::move(handler)]() mutable { // + std::move(handler)(boost::system::error_code()); + }); + } +}; + +// ------------------------------------------------------------------------------------------------- + +/** + * The implementation for a concrete stream, which is moved in and owned here -- among other things + * so that the socket underneath it stays around for cancellation. Everything beyond the async read + * and write operations comes from \c stream_traits, so this works for every stream a session can + * run on. + */ +template +class any_async_stream_impl final : public any_async_stream::Impl +{ +public: + explicit any_async_stream_impl(Stream stream) : m_stream(std::move(stream)) {} + + executor_type get_executor() noexcept override + { + return stream_traits::get_executor(m_stream); + } + TcpSocketBase& get_socket() override { return anyhttp::get_socket(m_stream); } + + void async_write_some(ReadWriteHandler handler, ConstBufferVector buffers) override + { + m_stream.async_write_some(buffers, std::move(handler)); + } + + void async_read_some(ReadWriteHandler handler, MutableBufferVector buffers) override + { + m_stream.async_read_some(buffers, std::move(handler)); + } + +private: + Stream m_stream; +}; + +// ------------------------------------------------------------------------------------------------- + +template + requires(!std::is_reference_v) +any_async_stream make_any_async_stream(Stream&& stream) +{ + return any_async_stream(std::make_unique>(std::move(stream))); +} + +extern template class any_async_stream_impl; +extern template class any_async_stream_impl; + +// ================================================================================================= + +} // namespace anyhttp diff --git a/include/anyhttp/h2_detect.hpp b/include/anyhttp/detail/detect_h2.hpp similarity index 100% rename from include/anyhttp/h2_detect.hpp rename to include/anyhttp/detail/detect_h2.hpp diff --git a/include/anyhttp/detect_ssl.hpp b/include/anyhttp/detail/detect_ssl.hpp similarity index 100% rename from include/anyhttp/detect_ssl.hpp rename to include/anyhttp/detail/detect_ssl.hpp diff --git a/include/anyhttp/detail/h2_session_details.hpp b/include/anyhttp/detail/h2_session_details.hpp index 98e2be5..93a255e 100644 --- a/include/anyhttp/detail/h2_session_details.hpp +++ b/include/anyhttp/detail/h2_session_details.hpp @@ -5,10 +5,10 @@ // factories in anyhttp/h2_backend.hpp are what the generic server and client use instead. // -#include "anyhttp/any_async_stream.hpp" #include "anyhttp/h2_common.hpp" #include "anyhttp/h2_session.hpp" #include "anyhttp/literals.hpp" +#include "anyhttp/stream_traits.hpp" #include #include @@ -16,7 +16,6 @@ #include #include #include -#include #include #include #include @@ -34,11 +33,6 @@ using namespace boost::asio; using namespace boost::beast; using socket = asio::ip::tcp::socket; -inline auto& get_socket(socket& socket) { return socket; } -inline auto& get_socket(tcp_stream& stream) { return stream.socket(); } -inline auto& get_socket(ssl::stream& stream) { return stream.lowest_layer(); } -inline auto& get_socket(AnyAsyncStream& stream) { return stream.get_socket(); } - // ================================================================================================= template @@ -113,7 +107,7 @@ awaitable NGHttp2SessionImpl::send_loop() { const std::array seq{buffer.data(), asio::buffer(data, nread)}; mylogd("send loop: writing {} bytes...", bytes_to_write); - auto [ec, written] = co_await asio::async_write(m_stream, seq, as_tuple(deferred)); + auto [ec, written] = co_await asio::async_write(m_stream, seq, as_tuple); if (ec) { mloge("send loop: error writing {} bytes: {}", bytes_to_write, ec.message()); @@ -166,7 +160,7 @@ awaitable NGHttp2SessionImpl::recv_loop() while (nghttp2_session_want_read(session) || nghttp2_session_want_write(session)) { auto free = m_buffer.capacity() - m_buffer.size(); - auto [ec, n] = co_await m_stream.async_read_some(m_buffer.prepare(free), as_tuple(deferred)); + auto [ec, n] = co_await m_stream.async_read_some(m_buffer.prepare(free), as_tuple); if (ec) { mylogd("read: {}, terminating session", ec.message()); @@ -201,7 +195,6 @@ template awaitable ServerSession::do_session(Buffer&& buffer) { m_buffer = std::move(buffer); - // get_socket(m_stream).set_option(ip::tcp::no_delay(true)); auto callbacks = super::setup_callbacks(); // @@ -298,7 +291,6 @@ template awaitable ClientSession::do_session(Buffer&& buffer) { m_buffer = std::move(buffer); - // get_socket(m_stream).set_option(ip::tcp::no_delay(true)); auto callbacks = super::setup_callbacks(); // diff --git a/include/anyhttp/h1_backend.hpp b/include/anyhttp/h1_backend.hpp index 05985ce..ab8e3f6 100644 --- a/include/anyhttp/h1_backend.hpp +++ b/include/anyhttp/h1_backend.hpp @@ -7,12 +7,11 @@ // and h1_session.cpp, which are the only places instantiating them. // -#include "anyhttp/any_async_stream.hpp" #include "anyhttp/client_impl.hpp" #include "anyhttp/server_impl.hpp" #include "anyhttp/session_impl.hpp" +#include "anyhttp/stream_traits.hpp" -#include #include #include @@ -23,23 +22,31 @@ namespace anyhttp::beast_impl // ================================================================================================= -using SslStream = boost::asio::ssl::stream; +// +// The stream is moved into the session, which runs on the stream's own executor -- hence the +// rvalue reference, which also keeps the SocketStream constraint from matching an lvalue. +// +// These are defined in src/h1_session.cpp and explicitly instantiated there for each of the +// stream types below, so that beast's HTTP machinery is instantiated in that one place only. +// -std::shared_ptr make_server_session(server::Server::Impl& server, - boost::asio::any_io_executor executor, - SslStream&& stream); +template +std::shared_ptr make_server_session(server::Server::Impl& server, Stream&& stream); -std::shared_ptr make_server_session(server::Server::Impl& server, - boost::asio::any_io_executor executor, - AnyAsyncStream&& stream); +template +std::shared_ptr make_client_session(client::Client::Impl& client, Stream&& stream); -std::shared_ptr make_server_session(server::Server::Impl& server, - boost::asio::any_io_executor executor, - boost::asio::ip::tcp::socket&& socket); +extern template std::shared_ptr +make_server_session(server::Server::Impl&, + boost::asio::ip::tcp::socket&&); +extern template std::shared_ptr +make_server_session(server::Server::Impl&, SslStream&&); +extern template std::shared_ptr +make_server_session(server::Server::Impl&, any_async_stream&&); -std::shared_ptr make_client_session(client::Client::Impl& client, - boost::asio::any_io_executor executor, - boost::asio::ip::tcp::socket&& socket); +extern template std::shared_ptr +make_client_session(client::Client::Impl&, + boost::asio::ip::tcp::socket&&); // ================================================================================================= diff --git a/include/anyhttp/h1_session.hpp b/include/anyhttp/h1_session.hpp index c025b22..da7a979 100644 --- a/include/anyhttp/h1_session.hpp +++ b/include/anyhttp/h1_session.hpp @@ -119,7 +119,8 @@ class ServerSession : public ServerSessionBase, public BeastSession ServerSession(server::Server::Impl& parent, any_io_executor executor, Stream&& stream); void destroy() noexcept override; - void async_submit(SubmitHandler&& handler, boost::urls::url url, const Fields& headers) override; + void async_submit(SubmitHandler&& handler, std::string_view method, boost::urls::url url, + const Fields& headers) override; awaitable do_session(Buffer&& data) override; private: @@ -194,7 +195,8 @@ class ClientSession : public ClientSessionBase, public BeastSession public: ClientSession(client::Client::Impl& parent, any_io_executor executor, Stream&& stream); - void async_submit(SubmitHandler&& handler, boost::urls::url url, const Fields& headers) override; + void async_submit(SubmitHandler&& handler, std::string_view method, boost::urls::url url, + const Fields& headers) override; awaitable do_session(Buffer&& data) override; // ---------------------------------------------------------------------------------------------- diff --git a/include/anyhttp/h2_backend.hpp b/include/anyhttp/h2_backend.hpp index fd73871..327a96d 100644 --- a/include/anyhttp/h2_backend.hpp +++ b/include/anyhttp/h2_backend.hpp @@ -7,17 +7,17 @@ // and h2_session.cpp, so that dispatching to HTTP/2 needs no nghttp2 type here. // -#include "anyhttp/any_async_stream.hpp" #include "anyhttp/client_impl.hpp" #include "anyhttp/server_impl.hpp" #include "anyhttp/session_impl.hpp" +#include "anyhttp/stream_traits.hpp" -#include #include #include #include #include +#include #include namespace anyhttp::nghttp2 @@ -25,8 +25,6 @@ namespace anyhttp::nghttp2 // ================================================================================================= -using SslStream = boost::asio::ssl::stream; - /** * A request received as HTTP/1.1 with "Upgrade: h2c" (RFC 7540, section 3.2) that has been answered * with "101 Switching Protocols". The HTTP/2 session continues it as stream 1. Only requests without @@ -40,34 +38,34 @@ struct Upgrade Fields fields; ///< request headers, without the connection-specific ones }; -std::shared_ptr make_server_session(server::Server::Impl& server, - boost::asio::any_io_executor executor, - boost::asio::ip::tcp::socket&& socket); - -std::shared_ptr make_server_session(server::Server::Impl& server, - boost::asio::any_io_executor executor, - SslStream&& stream); - -std::shared_ptr make_server_session(server::Server::Impl& server, - boost::asio::any_io_executor executor, - AnyAsyncStream&& stream); - -// Cleartext only: there is no upgrade to HTTP/2 over TLS, that is what ALPN is for. - -std::shared_ptr make_server_session(server::Server::Impl& server, - boost::asio::any_io_executor executor, - boost::asio::ip::tcp::socket&& socket, - Upgrade&& upgrade); - -std::shared_ptr make_server_session(server::Server::Impl& server, - boost::asio::any_io_executor executor, - AnyAsyncStream&& stream, Upgrade&& upgrade); - -// ------------------------------------------------------------------------------------------------- +// +// The stream is moved into the session, which runs on the stream's own executor -- hence the +// rvalue reference, which also keeps the SocketStream constraint from matching an lvalue. +// +// These are defined in src/h2_session.cpp and explicitly instantiated there for each of the +// stream types below, so that nghttp2 is instantiated in that one place only. +// -std::shared_ptr make_client_session(client::Client::Impl& client, - boost::asio::any_io_executor executor, - boost::asio::ip::tcp::socket&& socket); +template +std::shared_ptr make_server_session(server::Server::Impl& server, Stream&& stream, + std::optional upgrade = {}); + +template +std::shared_ptr make_client_session(client::Client::Impl& client, Stream&& stream); + +extern template std::shared_ptr +make_server_session(server::Server::Impl&, + boost::asio::ip::tcp::socket&&, + std::optional); +extern template std::shared_ptr +make_server_session(server::Server::Impl&, SslStream&&, std::optional); +extern template std::shared_ptr +make_server_session(server::Server::Impl&, any_async_stream&&, + std::optional); + +extern template std::shared_ptr +make_client_session(client::Client::Impl&, + boost::asio::ip::tcp::socket&&); // ================================================================================================= diff --git a/include/anyhttp/h2_session.hpp b/include/anyhttp/h2_session.hpp index e75dbae..908c483 100644 --- a/include/anyhttp/h2_session.hpp +++ b/include/anyhttp/h2_session.hpp @@ -70,7 +70,8 @@ class NGHttp2Session : public anyhttp::Session::Impl // ---------------------------------------------------------------------------------------------- - void async_submit(SubmitHandler&& handler, boost::urls::url url, const Fields& headers) override; + void async_submit(SubmitHandler&& handler, std::string_view method, boost::urls::url url, + const Fields& headers) override; // ---------------------------------------------------------------------------------------------- diff --git a/include/anyhttp/server_impl.hpp b/include/anyhttp/server_impl.hpp index 60608c7..858da93 100644 --- a/include/anyhttp/server_impl.hpp +++ b/include/anyhttp/server_impl.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -68,6 +69,11 @@ class Server::Impl : public std::enable_shared_from_this const Config& config() const { return m_config; } boost::asio::any_io_executor get_executor() const noexcept { return m_executor; } + // + // The TLS context used for every TCP connection, see make_tls_server_context(). + // + boost::asio::ssl::context& tls_context() noexcept { return m_tlsContext; } + asio::awaitable tcp_accept_loop(); asio::awaitable handle_connection(asio::ip::tcp::socket socket); @@ -93,6 +99,7 @@ class Server::Impl : public std::enable_shared_from_this Config m_config; boost::asio::any_io_executor m_executor; + boost::asio::ssl::context m_tlsContext; std::optional m_acceptor; std::mutex m_sessionMutex; diff --git a/include/anyhttp/session.hpp b/include/anyhttp/session.hpp index ac92d0b..30348f5 100644 --- a/include/anyhttp/session.hpp +++ b/include/anyhttp/session.hpp @@ -14,6 +14,9 @@ namespace anyhttp using Submit = void(boost::system::error_code, client::Request); using SubmitHandler = boost::asio::any_completion_handler; +using Get = void(boost::system::error_code, client::Message); +using GetHandler = boost::asio::any_completion_handler; + class Session { public: @@ -69,10 +72,52 @@ class Session token, std::move(url), headers); } + /** + * Performs a whole GET request in one operation, and hands back the whole response. + * + * This is the short way through what \ref async_submit() spreads over four steps: it submits + * the request, ends its (empty) body, waits for the response and reads all of it into a + * \c client::Message -- status, header fields and body as a \c std::string: + * + * \code + * auto message = co_await session.async_get(url); + * EXPECT_EQ(message.result_int(), 200); + * EXPECT_EQ(message.body(), "Hello, World!"); + * \endcode + * + * The convenience is paid for with memory: the body is buffered in full, however large it + * turns out to be, as there is no way to look at it before it is complete. Anything that needs + * the body while it arrives, a request body of its own, or a method other than GET still wants + * \ref async_submit(). + * + * The request goes out with "Content-Length: 0" unless \p headers already frames a body, so + * that HTTP/1.1 does not have to make it chunked. + * + * Any error along the way completes this operation: the ones \ref async_submit() describes, + * \c http::error::header_limit for a response header section over + * \c client::Config::max_header_size, and \c http::error::partial_message for a body cut + * short. The message that comes with an error is empty, and says \c status::unknown rather + * than the 200 a default-constructed Beast response would claim. A response that says 404, on + * the other hand, is not an error -- it is a response, and arrives as one. + */ + template + auto async_get(boost::urls::url url, const Fields& headers = {}, + CompletionToken&& token = CompletionToken()) + { + auto executor = asio::get_associated_executor(token, get_executor()); + return asio::async_initiate( + asio::bind_executor(executor, + [this](auto&& handler, boost::urls::url url, const Fields& headers) {// + async_get_any(std::move(handler), std::move(url), headers); + }), + token, std::move(url), headers); + } + boost::asio::any_io_executor get_executor() const noexcept; private: void async_submit_any(SubmitHandler&& handler, boost::urls::url url, const Fields& headers); + void async_get_any(GetHandler&& handler, boost::urls::url url, const Fields& headers); std::shared_ptr impl; }; diff --git a/include/anyhttp/session_impl.hpp b/include/anyhttp/session_impl.hpp index eb80f40..3bf3a7b 100644 --- a/include/anyhttp/session_impl.hpp +++ b/include/anyhttp/session_impl.hpp @@ -19,7 +19,8 @@ class Session::Impl : public std::enable_shared_from_this public: virtual ~Impl() {} virtual boost::asio::any_io_executor get_executor() const noexcept = 0; - virtual void async_submit(SubmitHandler&& handler, boost::urls::url url, const Fields& headers) = 0; + virtual void async_submit(SubmitHandler&& handler, std::string_view method, + boost::urls::url url, const Fields& headers) = 0; virtual asio::awaitable do_session(Buffer&& data) = 0; virtual void destroy() noexcept = 0; }; diff --git a/include/anyhttp/stream_traits.hpp b/include/anyhttp/stream_traits.hpp new file mode 100644 index 0000000..d2f745a --- /dev/null +++ b/include/anyhttp/stream_traits.hpp @@ -0,0 +1,114 @@ +#pragma once + +// +// The sessions are templates over the stream they run on, and there are four of those: a plain +// TCP socket, a TLS stream on top of one, beast's tcp_stream and the type-erased any_async_stream. +// Beyond the async read and write operations, which all of them have in common already, a session +// needs two more things from its stream: the underlying socket, to shut it down or close it, and +// an executor to run its loops on. Neither is spelled the same way by all four, so they are +// reached through this trait instead. +// + +#include "anyhttp/detail/any_async_stream.hpp" + +#include +#include +#include +#include + +#include + +namespace anyhttp +{ + +// ================================================================================================= + +/** + * Specialized below for every stream type a session can be instantiated with. The primary template + * is left undefined on purpose, so that \c SocketStream rejects anything else. + */ +template +struct stream_traits; + +/// A plain TCP socket is its own socket. +template +struct stream_traits> +{ + using stream_type = boost::asio::basic_stream_socket; + + static stream_type& get_socket(stream_type& stream) noexcept { return stream; } + static Executor get_executor(stream_type& stream) noexcept { return stream.get_executor(); } +}; + +/// A TLS stream, which may be layered on top of anything that has a socket at the bottom. +template +struct stream_traits> +{ + using stream_type = boost::asio::ssl::stream; + + static auto& get_socket(stream_type& stream) noexcept { return stream.lowest_layer(); } + static auto get_executor(stream_type& stream) noexcept { return stream.get_executor(); } +}; + +/// Beast's stream, which wraps a socket to add timeouts and a rate policy. +template +struct stream_traits> +{ + using stream_type = boost::beast::basic_stream; + + static auto& get_socket(stream_type& stream) noexcept { return stream.socket(); } + static auto get_executor(stream_type& stream) noexcept { return stream.get_executor(); } +}; + +/// The type-erased stream already offers both, its implementation has to provide them. +template <> +struct stream_traits +{ + using stream_type = any_async_stream; + + static auto& get_socket(stream_type& stream) noexcept { return stream.get_socket(); } + static auto get_executor(stream_type& stream) noexcept { return stream.get_executor(); } +}; + +// ------------------------------------------------------------------------------------------------- + +// +// What the four get_socket() above have in common is TcpSocketBase, declared next to the +// type-erased stream, which returns it directly. +// + +/** + * An async stream that is backed by a TCP socket and knows the executor it runs on -- in other + * words, something a session can be built on. Note that this deliberately does not match + * references: the factories taking it move the stream into the session they create. + */ +template +concept SocketStream = requires(Stream& stream) { + { stream_traits::get_socket(stream) } -> std::convertible_to; + { + stream_traits::get_executor(stream) + } -> std::convertible_to; +}; + +/** + * The socket underneath \p stream, for shutdown() and close(). There is no free function for the + * executor, because the classes calling this have a \c get_executor() of their own, which would + * hide it. + */ +template +decltype(auto) get_socket(Stream& stream) noexcept +{ + return stream_traits::get_socket(stream); +} + +// ------------------------------------------------------------------------------------------------- + +static_assert(SocketStream); +static_assert(SocketStream>); +static_assert(SocketStream); +static_assert(SocketStream); +static_assert(!SocketStream); // rvalues only, see above + +// ================================================================================================= + +} // namespace anyhttp diff --git a/pki/server.json b/pki/server.json index b8531c4..5ff79ff 100644 --- a/pki/server.json +++ b/pki/server.json @@ -6,8 +6,9 @@ }, "hosts": [ "localhost", + "anyhttp.org", "::1", "127.0.0.1", "127.0.0.2" ] -} \ No newline at end of file +} diff --git a/src/any_async_stream_impl.cpp b/src/any_async_stream_impl.cpp new file mode 100644 index 0000000..4328c85 --- /dev/null +++ b/src/any_async_stream_impl.cpp @@ -0,0 +1,48 @@ +#include "anyhttp/detail/any_async_stream_impl.hpp" + +namespace anyhttp +{ + +// ================================================================================================= + +// +// Everything that has to see the implementation, which is incomplete in the header the users of +// any_async_stream include. +// + +any_async_stream::any_async_stream(std::unique_ptr impl_) : impl(std::move(impl_)) {} +any_async_stream::any_async_stream(any_async_stream&&) noexcept = default; +any_async_stream& any_async_stream::operator=(any_async_stream&&) noexcept = default; +any_async_stream::~any_async_stream() = default; + +any_async_stream::executor_type any_async_stream::get_executor() noexcept +{ + return impl->get_executor(); +} + +TcpSocketBase& any_async_stream::get_socket() { return impl->get_socket(); } + +void any_async_stream::write_some(ReadWriteHandler handler, ConstBufferVector buffers) +{ + impl->async_write_some(std::move(handler), std::move(buffers)); +} + +void any_async_stream::read_some(ReadWriteHandler handler, MutableBufferVector buffers) +{ + impl->async_read_some(std::move(handler), std::move(buffers)); +} + +// ================================================================================================= +// The implementations, see anyhttp/detail/any_async_stream_impl.hpp. Instantiating them is kept to +// this translation unit, so that including the type-erased stream stays cheap. +// ================================================================================================= + +template class any_async_stream_impl; +template class any_async_stream_impl; + +template any_async_stream make_any_async_stream(ip::tcp::socket&&); +template any_async_stream make_any_async_stream(SslStream&&); + +// ================================================================================================= + +} // namespace anyhttp diff --git a/src/client_impl.cpp b/src/client_impl.cpp index 46b5e72..63eb720 100644 --- a/src/client_impl.cpp +++ b/src/client_impl.cpp @@ -16,7 +16,6 @@ #include #include -#include #include #include @@ -155,11 +154,11 @@ awaitable Client::Impl::async_connect() switch (config().protocol) { case Protocol::http11: - impl = beast_impl::make_client_session(*this, m_executor, std::move(socket)); + impl = beast_impl::make_client_session(*this, std::move(socket)); break; case Protocol::h2: - impl = nghttp2::make_client_session(*this, m_executor, std::move(socket)); + impl = nghttp2::make_client_session(*this, std::move(socket)); break; case anyhttp::Protocol::h3: diff --git a/src/h1_session.cpp b/src/h1_session.cpp index f237f73..2fdf1a5 100644 --- a/src/h1_session.cpp +++ b/src/h1_session.cpp @@ -1,12 +1,13 @@ #include "anyhttp/h1_session.hpp" -#include "anyhttp/any_async_stream.hpp" #include "anyhttp/common.hpp" +#include "anyhttp/detail/any_async_stream.hpp" #include "anyhttp/formatter.hpp" // IWYU pragma: keep #include "anyhttp/h1_backend.hpp" #include "anyhttp/h2_backend.hpp" #include "anyhttp/literals.hpp" #include "anyhttp/server.hpp" +#include "anyhttp/stream_traits.hpp" #include #include @@ -23,7 +24,6 @@ #include #include #include -#include #include #include #include @@ -62,11 +62,6 @@ using namespace asio; using namespace boost::beast; using socket = asio::ip::tcp::socket; -inline auto& get_socket(socket& socket) { return socket; } -inline auto& get_socket(tcp_stream& stream) { return stream.socket(); } -inline auto& get_socket(ssl::stream& stream) { return stream.lowest_layer(); } -inline auto& get_socket(AnyAsyncStream& stream) { return stream.get_socket(); } - /** * Adds the user's header fields to an outgoing message. A field replaces whatever the message * already has under that name, like a default set before, but repeated fields are all kept. @@ -562,7 +557,8 @@ class ResponseWriter mlogd("{} {}", message.result_int(), message.reason()); for (const auto& header : message) - mlogd(" \x1b[1;34m{}\x1b[0m: {}", truncated(header.name_string()), truncated(header.value())); + mlogd(" \x1b[1;34m{}\x1b[0m: {}", truncated(header.name_string()), + truncated(header.value())); // // TODO: For bundling writing the header and body, we should just post the writing here, @@ -752,7 +748,8 @@ class RequestWriter http::response_parser::value_type& msg = reader->parser.get(); mlogd("{} {}", msg.result_int(), msg.reason()); for (const auto& header : msg) - mlogd(" \x1b[1;34m{}\x1b[0m: {}", truncated(header.name_string()), truncated(header.value())); + mlogd(" \x1b[1;34m{}\x1b[0m: {}", truncated(header.name_string()), + truncated(header.value())); } else mlogw("async_read_header: {} len={}", ec.message(), len); @@ -936,31 +933,6 @@ static std::optional h2c_upgrade(const http::request make_h2c_session(server::Server::Impl& server, - any_io_executor executor, tcp_stream& stream, - nghttp2::Upgrade&& upgrade) -{ - return nghttp2::make_server_session(server, std::move(executor), stream.release_socket(), - std::move(upgrade)); -} - -static std::shared_ptr make_h2c_session(server::Server::Impl& server, - any_io_executor executor, - AnyAsyncStream& stream, - nghttp2::Upgrade&& upgrade) -{ - return nghttp2::make_server_session(server, std::move(executor), std::move(stream), - std::move(upgrade)); -} - -static std::shared_ptr make_h2c_session(server::Server::Impl&, any_io_executor, - ssl::stream&, nghttp2::Upgrade&&) -{ - throw std::logic_error("h2c upgrade over TLS"); // rejected by h2c_upgrade() -} - -// ================================================================================================= - /** * This function waits for headers of an incoming, new request and passes control to a registered * handler. After the request has been completed, and if the connection can be kept open, it starts @@ -980,11 +952,6 @@ awaitable ServerSession::do_session(Buffer&& buffer) m_buffer = std::move(buffer); mlogd("do_server_session, {} bytes in buffer", m_buffer.size()); - // get_socket(m_stream).set_option(asio::ip::tcp::no_delay(true)); - - // Set the timeout. TODO: don't rely on beast timeouts - // m_stream.expires_after(std::chrono::seconds(5)); - // m_stream.expires_never(); bool close = false; beast::error_code ec; @@ -1058,7 +1025,8 @@ awaitable ServerSession::do_session(Buffer&& buffer) mlogd("{} {} (need_eof={})", request.method_string(), reader->m_url.buffer(), need_eof); for (auto& header : request) - mlogd(" \x1b[1;34m{}\x1b[0m: {}", truncated(header.name_string()), truncated(header.value())); + mlogd(" \x1b[1;34m{}\x1b[0m: {}", truncated(header.name_string()), + truncated(header.value())); // // Upgrade to h2c, if requested: Answer with "101 Switching Protocols" and hand over the @@ -1079,8 +1047,10 @@ awaitable ServerSession::do_session(Buffer&& buffer) } mlogi("upgrading to h2c, {} bytes in buffer", m_buffer.size()); - m_upgraded = - make_h2c_session(server(), super::get_executor(), m_stream, std::move(*upgrade)); + // Stream is whatever this session runs on, but never a TLS one: h2c_upgrade() takes + // cleartext requests only, as h2 over TLS is negotiated by ALPN instead. + m_upgraded = nghttp2::make_server_session(server(), std::move(m_stream), + std::move(*upgrade)); co_await m_upgraded->do_session(std::move(m_buffer)); mlogi("h2c session done, served {} requests before upgrade", requestCounter - 1); co_return; @@ -1168,10 +1138,9 @@ awaitable ClientSession::do_session(Buffer&& buffer) m_buffer = std::move(buffer); mlogd("do_client_session, {} bytes in buffer", m_buffer.size()); - // get_socket(m_stream).set_option(asio::ip::tcp::no_delay(true)); // Set the low-level TCP stream timeout. This is relevant for some testcases... - m_stream.expires_after(5s); + // m_stream.expires_after(5s); // // Even in HTTP/1.1, where the current request and the current response's serializers take @@ -1201,22 +1170,22 @@ awaitable ClientSession::do_session(Buffer&& buffer) timer.expires_after(2s); co_await timer.async_wait(deferred); - // auto [ec, len] = co_await async_read_header(m_stream, buffer, parser, as_tuple(deferred)); + // auto [ec, len] = co_await async_read_header(m_stream, buffer, parser, as_tuple); co_return; } // ------------------------------------------------------------------------------------------------- template -void ServerSession::async_submit(SubmitHandler&& handler, boost::urls::url url, - const Fields& headers) +void ServerSession::async_submit(SubmitHandler&& handler, std::string_view method, + boost::urls::url url, const Fields& headers) { assert(false); } template -void ClientSession::async_submit(SubmitHandler&& handler, boost::urls::url url, - const Fields& headers) +void ClientSession::async_submit(SubmitHandler&& handler, std::string_view method, + boost::urls::url url, const Fields& headers) { // // Only one request can be incomplete at a time, see ClientSession. Instead of waiting for the @@ -1240,7 +1209,7 @@ void ClientSession::async_submit(SubmitHandler&& handler, boost::urls::u auto& request = writer->message; request.base().target(url.encoded_target()); - request.method(http::verb::post); + request.method_string(method); request.set(http::field::user_agent, "anyhttp"); add_fields(request, headers); if (request.find(http::field::host) == request.end()) @@ -1250,7 +1219,8 @@ void ClientSession::async_submit(SubmitHandler&& handler, boost::urls::u mlogd("{} {}", request.method_string(), url.buffer()); for (const auto& header : request) - mlogd(" \x1b[1;34m{}\x1b[0m: {}", truncated(header.name_string()), truncated(header.value())); + mlogd(" \x1b[1;34m{}\x1b[0m: {}", truncated(header.name_string()), + truncated(header.value())); writer->sequence = m_requests_sent++; m_sending = writer.get(); @@ -1318,47 +1288,38 @@ void ClientSession::reader_finished(bool complete) // ================================================================================================= -template class ClientSession; -template class ServerSession; +template class ServerSession; template class ServerSession>; -template class ServerSession; +template class ServerSession; // ================================================================================================= // Factories, see anyhttp/h1_backend.hpp. Instantiating the session templates is kept to this // translation unit, so that the generic server and client stay free of beast's HTTP machinery. // ================================================================================================= -std::shared_ptr make_server_session(server::Server::Impl& server, - asio::any_io_executor executor, - SslStream&& stream) +template +std::shared_ptr make_server_session(server::Server::Impl& server, Stream&& stream) { - return std::make_shared>(server, std::move(executor), - std::move(stream)); + auto executor = stream_traits::get_executor(stream); // before the stream is moved from + return std::make_shared>(server, std::move(executor), std::move(stream)); } -std::shared_ptr make_server_session(server::Server::Impl& server, - asio::any_io_executor executor, - AnyAsyncStream&& stream) +template +std::shared_ptr make_client_session(client::Client::Impl& client, Stream&& stream) { - return std::make_shared>(server, std::move(executor), - std::move(stream)); + auto executor = stream_traits::get_executor(stream); // before the stream is moved from + return std::make_shared>(client, std::move(executor), std::move(stream)); } -std::shared_ptr make_server_session(server::Server::Impl& server, - asio::any_io_executor executor, - asio::ip::tcp::socket&& socket) -{ - return std::make_shared>( - server, std::move(executor), boost::beast::tcp_stream(std::move(socket))); -} +template std::shared_ptr make_server_session(server::Server::Impl&, + socket&&); +template std::shared_ptr make_server_session(server::Server::Impl&, + SslStream&&); +template std::shared_ptr +make_server_session(server::Server::Impl&, any_async_stream&&); -std::shared_ptr make_client_session(client::Client::Impl& client, - asio::any_io_executor executor, - asio::ip::tcp::socket&& socket) -{ - return std::make_shared>( - client, std::move(executor), boost::beast::tcp_stream(std::move(socket))); -} +template std::shared_ptr make_client_session(client::Client::Impl&, + socket&&); // ================================================================================================= diff --git a/src/h2_session.cpp b/src/h2_session.cpp index d9d7aca..70ca792 100644 --- a/src/h2_session.cpp +++ b/src/h2_session.cpp @@ -16,7 +16,6 @@ #include #include -#include #include #include #include @@ -422,8 +421,8 @@ NGHttp2Session::~NGHttp2Session() // ================================================================================================= -void NGHttp2Session::async_submit(SubmitHandler&& handler, boost::urls::url url, - const Fields& headers) +void NGHttp2Session::async_submit(SubmitHandler&& handler, std::string_view method, + boost::urls::url url, const Fields& headers) { mlogi("submit: {}", url.buffer()); @@ -449,14 +448,14 @@ void NGHttp2Session::async_submit(SubmitHandler&& handler, boost::urls::url url, // TODO: CONNECT // https://datatracker.ietf.org/doc/html/rfc7540#section-8.3 // - std::string method("POST"); + std::string method_str(method); std::string scheme(url.scheme()); std::string target(url.encoded_target()); std::string authority(url.host_address()); auto nva = boost::container::small_vector(); nva.reserve(4 + std::distance(headers.begin(), headers.end())); - nva.push_back(make_nv_ls(":method", method)); + nva.push_back(make_nv_ls(":method", method_str)); nva.push_back(make_nv_ls(":scheme", scheme)); nva.push_back(make_nv_ls(":path", target)); nva.push_back(make_nv_ls(":authority", authority)); @@ -470,7 +469,7 @@ void NGHttp2Session::async_submit(SubmitHandler&& handler, boost::urls::url url, nva.push_back(make_nv_ls(item.name_string(), item.value())); } - logd("[{}] {} {}", stream->logPrefix, method, url.buffer()); + logd("[{}] {} {}", stream->logPrefix, method_str, url.buffer()); for (auto nv : nva) logd("[{}] \x1b[1;34m{}\x1b[0m: {}", stream->logPrefix, truncated(name_of(nv)), truncated(value_of(nv))); @@ -664,58 +663,34 @@ void NGHttp2Session::start_write() // translation unit, so that the generic server and client never see an nghttp2 type. // ================================================================================================= -std::shared_ptr make_server_session(server::Server::Impl& server, - asio::any_io_executor executor, - SslStream&& stream) +template +std::shared_ptr make_server_session(server::Server::Impl& server, Stream&& stream, + std::optional upgrade) { - return std::make_shared>(server, std::move(executor), - std::move(stream)); -} - -std::shared_ptr make_server_session(server::Server::Impl& server, - asio::any_io_executor executor, - AnyAsyncStream&& stream) -{ - return std::make_shared>(server, std::move(executor), - std::move(stream)); -} - -std::shared_ptr make_server_session(server::Server::Impl& server, - asio::any_io_executor executor, - asio::ip::tcp::socket&& socket) -{ - return std::make_shared>(server, std::move(executor), - std::move(socket)); -} - -std::shared_ptr make_server_session(server::Server::Impl& server, - asio::any_io_executor executor, - asio::ip::tcp::socket&& socket, - Upgrade&& upgrade) -{ - auto session = std::make_shared>( - server, std::move(executor), std::move(socket)); + auto executor = stream_traits::get_executor(stream); // before the stream is moved from + auto session = + std::make_shared>(server, std::move(executor), std::move(stream)); session->m_upgrade = std::move(upgrade); return session; } -std::shared_ptr make_server_session(server::Server::Impl& server, - asio::any_io_executor executor, - AnyAsyncStream&& stream, Upgrade&& upgrade) +template +std::shared_ptr make_client_session(client::Client::Impl& client, Stream&& stream) { - auto session = std::make_shared>(server, std::move(executor), - std::move(stream)); - session->m_upgrade = std::move(upgrade); - return session; + auto executor = stream_traits::get_executor(stream); // before the stream is moved from + return std::make_shared>(client, std::move(executor), std::move(stream)); } -std::shared_ptr make_client_session(client::Client::Impl& client, - asio::any_io_executor executor, - asio::ip::tcp::socket&& socket) -{ - return std::make_shared>(client, std::move(executor), - std::move(socket)); -} +template std::shared_ptr +make_server_session(server::Server::Impl&, socket&&, std::optional); +template std::shared_ptr +make_server_session(server::Server::Impl&, SslStream&&, std::optional); +template std::shared_ptr +make_server_session(server::Server::Impl&, any_async_stream&&, + std::optional); + +template std::shared_ptr make_client_session(client::Client::Impl&, + socket&&); // ================================================================================================= diff --git a/src/h3_client.cpp b/src/h3_client.cpp index 62a45a7..1ed7fbb 100644 --- a/src/h3_client.cpp +++ b/src/h3_client.cpp @@ -152,7 +152,8 @@ class Http3ClientStream : public http3::Http3Stream void submit_response(unsigned int, const Fields&) override {} /// Assembles and submits the request headers. Called once, right after the stream is created. - bool submit_request(const boost::urls::url& url, const Fields& headers); + bool submit_request(std::string_view method, const boost::urls::url& url, + const Fields& headers); void async_get_response(client::Request::GetResponseHandler&& handler); void deliver_response(); @@ -202,7 +203,8 @@ class Http3ClientSession : public http3::Http3Session // // Session::Impl // - void async_submit(SubmitHandler&& handler, boost::urls::url url, const Fields& headers) override; + void async_submit(SubmitHandler&& handler, std::string_view method, boost::urls::url url, + const Fields& headers) override; awaitable do_session(Buffer&& data) override; void destroy() noexcept override; @@ -309,15 +311,15 @@ void Http3ClientStream::deliver_failure() swap_and_invoke(response_handler, failure_ec, client::Response{nullptr}); } -bool Http3ClientStream::submit_request(const boost::urls::url& request_url, const Fields& headers) +bool Http3ClientStream::submit_request(std::string_view method, + const boost::urls::url& request_url, const Fields& headers) { url = request_url; // - // TODO: CONNECT / other methods -- mirrors the h2 client's NGHttp2Session::async_submit(), - // which is likewise hard-coded to POST. + // TODO: CONNECT // - std::string method_str("POST"); + std::string method_str(method); std::string scheme(request_url.scheme()); std::string target(request_url.encoded_target()); std::string authority(request_url.host_address()); @@ -649,8 +651,8 @@ int Http3ClientSession::on_read(std::span data) // ------------------------------------------------------------------------------------------------- -void Http3ClientSession::async_submit(SubmitHandler&& handler, boost::urls::url url, - const Fields& headers) +void Http3ClientSession::async_submit(SubmitHandler&& handler, std::string_view method, + boost::urls::url url, const Fields& headers) { if (closed() || !h3()) { @@ -668,7 +670,7 @@ void Http3ClientSession::async_submit(SubmitHandler&& handler, boost::urls::url } auto* stream = static_cast(create_stream(stream_id)); - if (!stream->submit_request(url, headers)) + if (!stream->submit_request(method, url, headers)) { erase_stream(stream_id); std::move(handler)(errc::make_error_code(errc::invalid_argument), client::Request{nullptr}); diff --git a/src/h3_server.cpp b/src/h3_server.cpp index 1db745a..5f7362e 100644 --- a/src/h3_server.cpp +++ b/src/h3_server.cpp @@ -114,7 +114,8 @@ namespace { // -// The process-wide BoringSSL SSL_CTX used for every QUIC connection. +// The BoringSSL SSL_CTX every QUIC connection of one server is served from. Owned by +// Http3ServerImpl, so it is built with the server and not on the first connection. // struct TlsServerContext { @@ -175,14 +176,6 @@ struct TlsServerContext SSL_CTX* ctx = nullptr; }; -TlsServerContext& tls_context() -{ - static TlsServerContext instance; - return instance; -} - -// ------------------------------------------------------------------------------------------------- - std::string cid_key(const ngtcp2_cid& cid) { return std::string{reinterpret_cast(cid.data), cid.datalen}; @@ -263,7 +256,8 @@ class Http3ServerSession : public http3::Http3Session // it, giving one QUIC connection the same single-threaded world a TCP connection gets from // the strand its socket lives on. // - void async_submit(SubmitHandler&& handler, boost::urls::url, const Fields&) override; + void async_submit(SubmitHandler&& handler, std::string_view, boost::urls::url, + const Fields&) override; awaitable do_session(Buffer&& data) override; void destroy() noexcept override; @@ -355,6 +349,13 @@ class Http3ServerImpl : public Http3Server, public std::enable_shared_from_this< const RequestHandler& requestHandler() const { return parent_.requestHandler(); } asio::any_io_executor get_executor() const noexcept { return parent_.get_executor(); } + // + // The TLS context every QUIC connection is served from, created with this server rather than + // lazily on the first connection, so that a certificate rotated afterwards is never picked up + // by one protocol only. See make_tls_server_context() on the TCP side. + // + SSL_CTX* tls_context() noexcept { return tls_.ctx; } + // // QUIC connection-ID demux table. Populated as new source CIDs are minted, consulted by // udp_on_read() to route packets to the right connection. Guarded by mutex_: the receive loop @@ -376,6 +377,8 @@ class Http3ServerImpl : public Http3Server, public std::enable_shared_from_this< private: Server::Impl& parent_; + TlsServerContext tls_; + // // The socket gets its own strand: udp_receive_loop() runs on it, and destroy() dispatches the // shutdown close() through it, so the two never touch the socket concurrently. @@ -527,7 +530,8 @@ Http3ServerSession::~Http3ServerSession() // ------------------------------------------------------------------------------------------------- -void Http3ServerSession::async_submit(SubmitHandler&& handler, boost::urls::url, const Fields&) +void Http3ServerSession::async_submit(SubmitHandler&& handler, std::string_view, boost::urls::url, + const Fields&) { // A server does not initiate requests; see Http3ClientSession::async_submit(). std::move(handler)(errc::make_error_code(errc::operation_not_supported), @@ -740,7 +744,7 @@ int Http3ServerSession::init(const ngtcp2_cid& dcid, const ngtcp2_cid& scid, uin return -1; } - if (setup_tls(tls_context().ctx, true /* server */) != 0) + if (setup_tls(server_.tls_context(), true /* server */) != 0) return -1; logi("[{}] new connection, scid={} version=0x{:x}", log_prefix_, diff --git a/src/server_impl.cpp b/src/server_impl.cpp index be114e5..efe2d0d 100644 --- a/src/server_impl.cpp +++ b/src/server_impl.cpp @@ -1,11 +1,11 @@ #include "anyhttp/server_impl.hpp" -#include "anyhttp/any_async_stream.hpp" -#include "anyhttp/detect_ssl.hpp" +#include "anyhttp/detail/any_async_stream.hpp" +#include "anyhttp/detail/detect_h2.hpp" +#include "anyhttp/detail/detect_ssl.hpp" #include "anyhttp/formatter.hpp" // IWYU pragma: keep #include "anyhttp/h1_backend.hpp" #include "anyhttp/h2_backend.hpp" -#include "anyhttp/h2_detect.hpp" #include "anyhttp/h3_backend.hpp" #include "anyhttp/tls.hpp" @@ -55,8 +55,14 @@ Response::Impl::~Impl() = default; // ================================================================================================= +// +// Defined further down, together with the ALPN callbacks it installs. +// +static asio::ssl::context make_tls_server_context(); + Server::Impl::Impl(boost::asio::any_io_executor executor, Config config) - : m_config(std::move(config)), m_executor(std::move(executor)), m_acceptor(m_executor) + : m_config(std::move(config)), m_executor(std::move(executor)), + m_tlsContext(make_tls_server_context()), m_acceptor(m_executor) { logi("Server: ctor"); listen_tcp(); @@ -212,28 +218,28 @@ static int alpn_select_proto_cb(SSL* ssl, const unsigned char** out, unsigned ch return SSL_TLSEXT_ERR_NOACK; } -// ------------------------------------------------------------------------------------------------- - -class TestStream : public AnyAsyncStream::Impl +// +// The TLS context every TCP connection is served from. It is created once, when the server is +// constructed, and not per connection: building it reads the PEM files from disk, and a context +// built per connection would also pick up a certificate that was rotated underneath a running +// server -- unlike HTTP/3, which holds its context for the lifetime of the server. That +// difference made a regenerated test PKI fail over HTTP/3 while HTTP/2 silently kept working. +// +static asio::ssl::context make_tls_server_context() { -public: - TestStream(ip::tcp::socket socket) : socket_(std::move(socket)) {} - executor_type get_executor() noexcept override { return socket_.get_executor(); } + asio::ssl::context ctx{asio::ssl::context::tlsv13}; + SSL_CTX_set_next_protos_advertised_cb(ctx.native_handle(), next_proto_cb, NULL); + SSL_CTX_set_alpn_select_cb(ctx.native_handle(), alpn_select_proto_cb, NULL); - ip::tcp::socket& get_socket() final { return socket_; } - void async_write_some(ReadWriteHandler handler, ConstBuffers buffers) final - { - socket_.async_write_some(buffers, std::move(handler)); - } - - void async_read_some(ReadWriteHandler handler, MutableBuffers buffers) final - { - socket_.async_read_some(buffers, std::move(handler)); - } + // + // This is a testing key only. It is not in the repository, but generated at build time + // by the 'pki' target (see cmake/pki.cmake). + // + ctx.use_certificate_chain_file("pki/out/server-chain.pem"); + ctx.use_private_key_file("pki/out/server-key.pem", asio::ssl::context::pem); -private: - ip::tcp::socket socket_; // the underlying socket, for cancellation -}; + return ctx; +} // ------------------------------------------------------------------------------------------------- @@ -259,7 +265,6 @@ awaitable Server::Impl::handle_connection(ip::tcp::socket socket) // socket.set_option(sb::send_buffer_size(8192)); // socket.set_option(sb::receive_buffer_size(8192)); // makes 'PostRange' testcases very slow - auto executor = co_await boost::asio::this_coro::executor; auto buffer = boost::beast::flat_buffer(); // @@ -271,18 +276,7 @@ awaitable Server::Impl::handle_connection(ip::tcp::socket socket) { logi("[{}] detected TLS client hello, {} bytes in buffer", prefix, buffer.size()); - asio::ssl::context ctx{asio::ssl::context::tlsv13}; - SSL_CTX_set_next_protos_advertised_cb(ctx.native_handle(), next_proto_cb, NULL); - SSL_CTX_set_alpn_select_cb(ctx.native_handle(), alpn_select_proto_cb, NULL); - - // - // This is a testing key only. It is not in the repository, but generated at build time - // by the 'pki' target (see cmake/pki.cmake). - // - ctx.use_certificate_chain_file("pki/out/server-chain.pem"); - ctx.use_private_key_file("pki/out/server-key.pem", asio::ssl::context::pem); - - ssl_stream.emplace(std::move(socket), ctx); + ssl_stream.emplace(std::move(socket), m_tlsContext); auto n = co_await ssl_stream->async_handshake(asio::ssl::stream_base::server, buffer.data()); buffer.consume(n); @@ -302,9 +296,9 @@ awaitable Server::Impl::handle_connection(ip::tcp::socket socket) tls_handshake_info(ssl_stream->native_handle())); if (alpn == "h2") - session = nghttp2::make_server_session(*this, executor, std::move(*ssl_stream)); + session = nghttp2::make_server_session(*this, std::move(*ssl_stream)); else if (alpn == "http/1.1") - session = beast_impl::make_server_session(*this, executor, std::move(*ssl_stream)); + session = beast_impl::make_server_session(*this, std::move(*ssl_stream)); } // @@ -314,10 +308,9 @@ awaitable Server::Impl::handle_connection(ip::tcp::socket socket) { logi("[{}] detected HTTP2 client preface, {} bytes in buffer", prefix, buffer.size()); #if 1 - AnyAsyncStream stream(std::make_unique(std::move(socket))); - session = nghttp2::make_server_session(*this, executor, std::move(stream)); + session = nghttp2::make_server_session(*this, make_any_async_stream(std::move(socket))); #else - session = nghttp2::make_server_session(*this, executor, std::move(socket)); + session = nghttp2::make_server_session(*this, std::move(socket)); #endif } @@ -328,10 +321,9 @@ awaitable Server::Impl::handle_connection(ip::tcp::socket socket) { logi("[{}] no HTTP2 client preface, assuming HTTP/1.x", prefix); #if 1 - AnyAsyncStream stream(std::make_unique(std::move(socket))); - session = beast_impl::make_server_session(*this, executor, std::move(stream)); + session = beast_impl::make_server_session(*this, make_any_async_stream(std::move(socket))); #else - session = beast_impl::make_server_session(*this, executor, std::move(socket)); + session = beast_impl::make_server_session(*this, std::move(socket)); #endif } @@ -381,7 +373,16 @@ awaitable Server::Impl::tcp_accept_loop() size_t sessionCounter = 0; for (;;) { - auto [ec, socket] = co_await acceptor.async_accept(as_tuple(deferred)); + // + // Put each connection on a strand if needed. The socket is accepted onto that executor, + // so that everything layered on top of it stays there, too: the session takes its executor + // from the stream it is given, see the make_*_session() factories. + // + // NOTE: This is slow. Consider multiple IO contexts instead, + // or explicit thread pools where really needed. + // + ip::tcp::socket socket(config().use_strand ? boost::asio::make_strand(executor) : executor); + auto [ec] = co_await acceptor.async_accept(socket, as_tuple); if (ec) { if (ec == boost::system::errc::operation_canceled) @@ -403,14 +404,9 @@ awaitable Server::Impl::tcp_accept_loop() ++sessionCounter; } - // - // Put each connection on a strand if needed. - // - // NOTE: This is slow. Consider multiple IO contexts instead, - // or explicit thread pools where really needed. - // - co_spawn(config().use_strand ? boost::asio::make_strand(executor) : executor, - handle_connection(std::move(socket)), [&, ep](const std::exception_ptr& ex) mutable + auto connection_executor = socket.get_executor(); + co_spawn(connection_executor, handle_connection(std::move(socket)), + [&, ep](const std::exception_ptr& ex) mutable { auto lock = std::lock_guard(m_sessionMutex); --sessionCounter; diff --git a/src/session.cpp b/src/session.cpp index d57bf5a..066ef52 100644 --- a/src/session.cpp +++ b/src/session.cpp @@ -1,6 +1,14 @@ #include "anyhttp/session.hpp" +#include "anyhttp/literals.hpp" #include "anyhttp/session_impl.hpp" +#include +#include +#include +#include + +#include + using namespace boost::asio; namespace anyhttp @@ -50,7 +58,98 @@ boost::asio::any_io_executor Session::get_executor() const noexcept void Session::async_submit_any(SubmitHandler&& handler, boost::urls::url url, const Fields& headers) { - impl->async_submit(std::move(handler), url, std::move(headers)); + impl->async_submit(std::move(handler), "POST", url, std::move(headers)); +} + +// ================================================================================================= + +namespace +{ + +// +// Submits a request with a method of its own. The public Session::async_submit() is POST-only, +// but every backend can send whatever method it is handed, see Session::Impl::async_submit(). +// +template +auto async_submit(Session::Impl& impl, std::string_view method, boost::urls::url url, + const Fields& headers, CompletionToken&& token) +{ + return asio::async_initiate( + [&impl](auto&& handler, std::string_view method, boost::urls::url url, + const Fields& headers) { // + impl.async_submit(std::move(handler), method, std::move(url), headers); + }, + token, method, std::move(url), headers); +} + +// +// The whole of async_get(), as the coroutine it reads best as: submit, end the empty request +// body, wait for the response, read all of it. Every step is left to throw, and the co_spawn() +// below turns that back into the error code the caller gets. +// +// The implementation is held by shared_ptr: a Session released while the GET is still in flight +// must not pull the ground out from under the operations still running on it. +// +awaitable get_message(std::shared_ptr session, + boost::urls::url url, Fields headers) +{ + // + // A GET has no body, and saying so with a "Content-Length: 0" keeps HTTP/1.1 from framing one + // as chunked -- which would leave the request incomplete until the write_eof() below, and the + // session unable to take another one until then. + // + if (!headers.count(boost::beast::http::field::content_length) && + !headers.count(boost::beast::http::field::transfer_encoding)) + headers.set(boost::beast::http::field::content_length, "0"); + + auto request = co_await async_submit(*session, "GET", std::move(url), headers, deferred); + co_await request.async_write_eof(); + auto response = co_await request.async_get_response(); + + client::Message message; + message.result(static_cast(response.status_code())); + for (auto&& field : response.fields()) + message.insert(field.name_string(), field.value()); // insert(), so repeated fields survive + + auto& body = message.body(); + std::array buffer; + for (;;) + { + auto [ec, n] = co_await response.async_read_some(asio::buffer(buffer), as_tuple); + body.append(buffer.data(), n); + + if (ec == asio::error::eof) + break; + else if (ec) + throw boost::system::system_error(ec); + } + + logd("async_get: {} {}, {} bytes", message.result_int(), message.reason(), body.size()); + co_return std::move(message); +} + +} // namespace + +void Session::async_get_any(GetHandler&& handler, boost::urls::url url, const Fields& headers) +{ + auto executor = get_associated_executor(handler, get_executor()); + auto slot = get_associated_cancellation_slot(handler); + + // + // co_spawn() gives the coroutine a cancellation slot of its own, so binding the caller's to it + // is what makes cancelling async_get() reach the operation it is currently waiting for. + // + co_spawn(get_executor(), get_message(impl, std::move(url), headers), + bind_cancellation_slot( + slot, bind_executor(executor, [handler = std::move(handler)]( + const std::exception_ptr& ep, + client::Message message) mutable + { + auto ec = code(ep); + if (ec) + message.result(boost::beast::http::status::unknown); // not the Beast default + std::move(handler)(ec, std::move(message)); + }))); } // ================================================================================================= diff --git a/src/utils.cpp b/src/utils.cpp index 7210c21..f9761ce 100644 --- a/src/utils.cpp +++ b/src/utils.cpp @@ -23,7 +23,7 @@ size_t run(boost::asio::io_context& context) std::println("--- {} ------------------------------------------------------------------------", i); else std::println("\x1b[1;31m--- {} ({}) ----------------------------------------------------------------\x1b[0m", i, dt); - // clang-format off + // clang-format on } return i; } @@ -33,16 +33,17 @@ size_t run(boost::asio::io_context& context) unsigned short get_unused_port(boost::asio::io_context& io) { - boost::asio::ip::tcp::acceptor acc(io); + using namespace boost::asio::ip; - acc.open(boost::asio::ip::tcp::v4()); - acc.bind({boost::asio::ip::address_v4::loopback(), 0}); + tcp::acceptor acc(io); + acc.open(tcp::v6()); + acc.bind({address_v6::loopback(), 0}); - unsigned short port = acc.local_endpoint().port(); + auto port = acc.local_endpoint().port(); - acc.close(); // release immediately + acc.close(); // release immediately - return port; + return port; } // ================================================================================================= diff --git a/test/test_client_async.cpp b/test/test_client_async.cpp index 0275759..4e5c701 100644 --- a/test/test_client_async.cpp +++ b/test/test_client_async.cpp @@ -496,10 +496,7 @@ TEST_P(ClientAsync, WHEN_written_after_eof_THEN_reports_broken_pipe) }; test = [this](Session session) -> awaitable { - auto request = co_await session.async_submit(url); - co_await request.async_write_eof(); - auto response = co_await request.async_get_response(); - EXPECT_EQ(co_await read(response), hello); + EXPECT_EQ((co_await session.async_get(url)).body(), hello); }; } @@ -513,11 +510,9 @@ TEST_P(ClientAsync, HelloWorld) }; test = [this](Session session) -> awaitable { - auto request = co_await session.async_submit(url); - co_await request.async_write_eof(); - auto response = co_await request.async_get_response(); - auto body = co_await read(response); - EXPECT_EQ(body, hello); + auto message = co_await session.async_get(url); + EXPECT_EQ(message.result_int(), 200); + EXPECT_EQ(message.body(), hello); }; } @@ -548,9 +543,7 @@ TEST_P(ClientAsync, WHEN_server_writes_large_buffer_at_once_THEN_receives_all) }; test = [this](Session session) -> awaitable { - auto request = co_await session.async_submit(url); - co_await request.async_write_eof(); - EXPECT_EQ(co_await count_response(request), body.size()); + EXPECT_EQ((co_await session.async_get(url)).body().size(), body.size()); }; } @@ -686,9 +679,7 @@ TEST_P(ClientAsync, ServerYieldFirst) }; test = [this](Session session) -> awaitable { - auto request = co_await session.async_submit(url); - co_await request.async_write_eof(); - co_await count_response(request); + EXPECT_EQ((co_await session.async_get(url)).result_int(), 200); }; } diff --git a/test/test_file_handler.cpp b/test/test_file_handler.cpp index 7d085ec..16c8e03 100644 --- a/test/test_file_handler.cpp +++ b/test/test_file_handler.cpp @@ -54,20 +54,13 @@ class FileHandler : public ClientAsync std::ofstream(path, std::ios::binary).write(content.data(), content.size()); } - // - // Requests \p target and returns status code and body. The request is finished right away -- - // serve_file() ignores the request body, but still has to consume it. - // - awaitable> get(Session& session, boost::urls::url target) - { - auto request = co_await session.async_submit(target, {}); - co_await request.async_write_eof(); - auto response = co_await request.async_get_response(); - auto body = co_await read(response); - co_return std::make_tuple(response.status_code(), std::move(body)); + /// Requests \p target and returns the whole response, body and all. + awaitable get(Session& session, boost::urls::url target) + { + co_return co_await session.async_get(std::move(target)); } - awaitable> get(Session& session, std::string_view path) + awaitable get(Session& session, std::string_view path) { co_return co_await get(session, boost::urls::url(url).set_path(path)); } @@ -98,9 +91,9 @@ TEST_P(FileHandler, WHEN_file_exists_THEN_serves_content) { test = [this](Session session) -> awaitable { - auto [status, body] = co_await get(session, "/custom/hello.txt"); - EXPECT_EQ(status, 200); - EXPECT_EQ(body, "Hello, File!"); + auto message = co_await get(session, "/custom/hello.txt"); + EXPECT_EQ(message.result_int(), 200); + EXPECT_EQ(message.body(), "Hello, File!"); }; } @@ -108,9 +101,9 @@ TEST_P(FileHandler, WHEN_file_is_in_subdirectory_THEN_serves_content) { test = [this](Session session) -> awaitable { - auto [status, body] = co_await get(session, "/custom/sub/nested.txt"); - EXPECT_EQ(status, 200); - EXPECT_EQ(body, "Nested!"); + auto message = co_await get(session, "/custom/sub/nested.txt"); + EXPECT_EQ(message.result_int(), 200); + EXPECT_EQ(message.body(), "Nested!"); }; } @@ -122,9 +115,9 @@ TEST_P(FileHandler, WHEN_file_is_empty_THEN_serves_empty_body) { test = [this](Session session) -> awaitable { - auto [status, body] = co_await get(session, "/custom/empty.txt"); - EXPECT_EQ(status, 200); - EXPECT_EQ(body, ""); + auto message = co_await get(session, "/custom/empty.txt"); + EXPECT_EQ(message.result_int(), 200); + EXPECT_THAT(message.body(), IsEmpty()); }; } @@ -136,9 +129,9 @@ TEST_P(FileHandler, WHEN_file_is_large_THEN_serves_all_of_it) { test = [this](Session session) -> awaitable { - auto [status, body] = co_await get(session, "/custom/large.bin"); - EXPECT_EQ(status, 200); - EXPECT_EQ(body, std::string(256_k, 'x')); + auto message = co_await get(session, "/custom/large.bin"); + EXPECT_EQ(message.result_int(), 200); + EXPECT_EQ(message.body(), std::string(256_k, 'x')); }; } @@ -146,9 +139,9 @@ TEST_P(FileHandler, WHEN_file_does_not_exist_THEN_error_404) { test = [this](Session session) -> awaitable { - auto [status, body] = co_await get(session, "/custom/missing.txt"); - EXPECT_EQ(status, 404); - EXPECT_EQ(body, ""); + auto message = co_await get(session, "/custom/missing.txt"); + EXPECT_EQ(message.result_int(), 404); + EXPECT_THAT(message.body(), IsEmpty()); }; } @@ -159,8 +152,8 @@ TEST_P(FileHandler, WHEN_path_is_a_directory_THEN_error_404) { test = [this](Session session) -> awaitable { - EXPECT_EQ(std::get<0>(co_await get(session, "/custom/sub")), 404); - EXPECT_EQ(std::get<0>(co_await get(session, "/custom/")), 404); + EXPECT_EQ((co_await get(session, "/custom/sub")).result_int(), 404); + EXPECT_EQ((co_await get(session, "/custom/")).result_int(), 404); }; } @@ -168,9 +161,9 @@ TEST_P(FileHandler, WHEN_path_escapes_the_root_THEN_error_404) { test = [this](Session session) -> awaitable { - EXPECT_EQ(std::get<0>(co_await get(session, "/custom/../outside.txt")), 404); - EXPECT_EQ(std::get<0>(co_await get(session, "/custom/sub/../../outside.txt")), 404); - EXPECT_EQ(std::get<0>(co_await get(session, encoded("/custom/%2e%2e/outside.txt"))), 404); + EXPECT_EQ((co_await get(session, "/custom/../outside.txt")).result_int(), 404); + EXPECT_EQ((co_await get(session, "/custom/sub/../../outside.txt")).result_int(), 404); + EXPECT_EQ((co_await get(session, encoded("/custom/%2e%2e/outside.txt"))).result_int(), 404); }; } @@ -181,7 +174,7 @@ TEST_P(FileHandler, WHEN_symlink_points_outside_the_root_THEN_error_404) { test = [this](Session session) -> awaitable { - EXPECT_EQ(std::get<0>(co_await get(session, "/custom/escape.txt")), 404); + EXPECT_EQ((co_await get(session, "/custom/escape.txt")).result_int(), 404); }; } @@ -193,10 +186,10 @@ TEST_P(FileHandler, WHEN_prefix_matches_mid_segment_THEN_error_404) { test = [this](Session session) -> awaitable { - auto [status, body] = co_await get(session, "/customer.txt"); - EXPECT_EQ(status, 404); - EXPECT_EQ(body, ""); - EXPECT_EQ(std::get<0>(co_await get(session, "/customer/hello.txt")), 404); + auto message = co_await get(session, "/customer.txt"); + EXPECT_EQ(message.result_int(), 404); + EXPECT_THAT(message.body(), IsEmpty()); + EXPECT_EQ((co_await get(session, "/customer/hello.txt")).result_int(), 404); }; } @@ -207,9 +200,9 @@ TEST_P(FileHandler, WHEN_file_is_not_readable_THEN_error_403) test = [this](Session session) -> awaitable { - auto [status, body] = co_await get(session, "/custom/secret.txt"); - EXPECT_EQ(status, 403); - EXPECT_EQ(body, ""); + auto message = co_await get(session, "/custom/secret.txt"); + EXPECT_EQ(message.result_int(), 403); + EXPECT_THAT(message.body(), IsEmpty()); }; } @@ -217,8 +210,8 @@ TEST_P(FileHandler, WHEN_same_file_is_requested_twice_THEN_serves_it_twice) { test = [this](Session session) -> awaitable { - EXPECT_EQ(std::get<1>(co_await get(session, "/custom/hello.txt")), "Hello, File!"); - EXPECT_EQ(std::get<1>(co_await get(session, "/custom/hello.txt")), "Hello, File!"); + EXPECT_EQ((co_await get(session, "/custom/hello.txt")).body(), "Hello, File!"); + EXPECT_EQ((co_await get(session, "/custom/hello.txt")).body(), "Hello, File!"); }; } diff --git a/test/test_get.cpp b/test/test_get.cpp new file mode 100644 index 0000000..2955c14 --- /dev/null +++ b/test/test_get.cpp @@ -0,0 +1,192 @@ +#include "test_fixtures.hpp" + +#include + +using namespace testing; + +namespace http = boost::beast::http; + +// ================================================================================================= + +// +// Session::async_get(): a whole GET request as a single operation, with the response handed back +// as a plain Beast message -- status, fields and the body as a string. +// +class AsyncGet : public ClientAsync +{ +protected: + /// Installs a request handler that drains the request and responds 200 with \p body. + void respond_with(std::string body) + { + custom = [body = std::move(body)](server::Request request, + server::Response response) -> awaitable + { + EXPECT_EQ(co_await drain(request), 0); // a GET has no body + co_await response.async_submit( + 200, fields({{"Content-Length", body.size()}, {"X-Answer", 42}})); + co_await response.async_write_eof(asio::buffer(body)); + }; + } +}; + +INSTANTIATE_TEST_SUITE_P(AsyncGet, AsyncGet, + Values(anyhttp::Protocol::http11, anyhttp::Protocol::h2, + anyhttp::Protocol::h3), + NameGenerator); + +// ------------------------------------------------------------------------------------------------- + +TEST_P(AsyncGet, WHEN_get_THEN_message_has_status_fields_and_body) +{ + respond_with("Hello, World!"); + test = [this](Session session) -> awaitable + { + auto message = co_await session.async_get(url); + EXPECT_EQ(message.result(), http::status::ok); + EXPECT_EQ(message.result_int(), 200); + EXPECT_EQ(message["x-answer"], "42"); + EXPECT_EQ(message.body(), "Hello, World!"); + }; +} + +TEST_P(AsyncGet, WHEN_response_has_no_body_THEN_body_is_empty) +{ + respond_with(""); + test = [this](Session session) -> awaitable + { + auto message = co_await session.async_get(url); + EXPECT_EQ(message.result_int(), 200); + EXPECT_THAT(message.body(), IsEmpty()); + }; +} + +// +// A response is a response, whatever it says: only a request that gets none at all -- like the +// cancelled one below -- completes with an error. +// +TEST_P(AsyncGet, WHEN_path_is_unknown_THEN_message_says_404) +{ + test = [this](Session session) -> awaitable + { + auto message = co_await session.async_get(url.set_path("unknown")); + EXPECT_EQ(message.result(), http::status::not_found); + }; +} + +TEST_P(AsyncGet, WHEN_body_is_large_THEN_all_of_it_arrives) +{ + auto body = std::string(1_m, 'x'); + respond_with(body); + test = [this, body](Session session) -> awaitable + { + auto message = co_await session.async_get(url); + EXPECT_EQ(message.result_int(), 200); + EXPECT_EQ(message.body().size(), body.size()); + EXPECT_EQ(message.body(), body); + }; +} + +TEST_P(AsyncGet, WHEN_headers_are_given_THEN_they_arrive_with_the_request) +{ + custom = [](server::Request request, server::Response response) -> awaitable + { + EXPECT_EQ(request.fields()["x-question"], "what?"); + EXPECT_EQ(request.fields()["content-length"], "0"); + co_await drain(request); + co_await response.async_submit(200, fields({{"Content-Length", 0}})); + co_await response.async_write_eof(); + }; + test = [this](Session session) -> awaitable + { + auto message = co_await session.async_get(url, fields({{"X-Question", "what?"}})); + EXPECT_EQ(message.result_int(), 200); + }; +} + +// +// A GET is complete as soon as it has been submitted, so even HTTP/1.1, which allows just one +// request in progress at a time, takes the next one right away. +// +TEST_P(AsyncGet, WHEN_two_requests_in_a_row_THEN_both_are_answered) +{ + respond_with("Hello, World!"); + test = [this](Session session) -> awaitable + { + for (size_t i = 0; i < 2; ++i) + { + auto message = co_await session.async_get(url); + EXPECT_EQ(message.result_int(), 200); + EXPECT_EQ(message.body(), "Hello, World!"); + } + }; +} + +// +// Cancellation reaches whichever of the steps the GET is waiting for, and comes out of the one +// operation the caller started. Nothing arrived, so the message stays empty. +// +TEST_P(AsyncGet, WHEN_cancelled_THEN_completes_with_operation_canceled_and_empty_message) +{ + // + // Responds late, and to nobody in particular: by then the client has given up, so writing to + // the stream is expected to fail. + // + custom = [](server::Request request, server::Response response) -> awaitable + { + co_await sleep(1s); + std::ignore = co_await response.async_submit(200, {}, as_tuple); + std::ignore = co_await response.async_write_eof(as_tuple); + }; + test = [this](Session session) -> awaitable + { + auto [ec, message] = co_await session.async_get(url, {}, cancel_after(100ms, as_tuple)); + EXPECT_EQ(ec, boost::system::errc::operation_canceled); + EXPECT_EQ(message.result_int(), 0); + EXPECT_THAT(message.body(), IsEmpty()); + }; +} + +// ================================================================================================= + +// +// What goes out on the wire, checked against a raw HTTP/1.1 peer: async_get() sends a GET, where +// async_submit() sends a POST, and frames the absent body with "Content-Length: 0" instead of +// making it chunked. HTTP/2 and HTTP/3 put the very same method string into ':method'. +// +TEST(AsyncGetRaw, WHEN_get_THEN_request_line_says_GET) +{ + setupLogging(); + io_context context; + tcp::acceptor acceptor(context, tcp::endpoint(ip::make_address("127.0.0.1"), 0)); + + std::string head; + co_spawn(context, [&]() -> awaitable + { + auto socket = co_await acceptor.async_accept(); + co_await async_read_until(socket, dynamic_buffer(head), "\r\n\r\n"); + constexpr auto response = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"sv; + co_await async_write(socket, buffer(response)); + socket.shutdown(tcp::socket::shutdown_send); + }, detached); + + auto url = boost::urls::url("http://127.0.0.1"); + url.set_port_number(acceptor.local_endpoint().port()); + + client::Client client(context.get_executor(), + {.url = url, .protocol = anyhttp::Protocol::http11}); + co_spawn(context, [&]() -> awaitable + { + auto session = co_await client.async_connect(); + auto message = co_await session.async_get(url.set_path("/index.html")); + EXPECT_EQ(message.result_int(), 200); + EXPECT_EQ(message.body(), "hello"); + }, [](const std::exception_ptr& ep) { EXPECT_FALSE(ep) << what(ep); }); + + context.run(); + + EXPECT_THAT(head, StartsWith("GET /index.html HTTP/1.1\r\n")); + EXPECT_THAT(head, HasSubstr("Content-Length: 0\r\n")); + EXPECT_THAT(head, Not(HasSubstr("chunked"))); +} + +// ================================================================================================= diff --git a/test/test_headers.cpp b/test/test_headers.cpp index 7848217..ebe410a 100644 --- a/test/test_headers.cpp +++ b/test/test_headers.cpp @@ -93,12 +93,10 @@ void Headers::round_trip(Fields sent) }; test = [this, sent](Session session) -> awaitable { - auto request = co_await session.async_submit(url, sent); - co_await request.async_write_eof(); - auto response = co_await request.async_get_response(); - EXPECT_EQ(response.status_code(), 200); - expect_contains(response.fields(), sent); - EXPECT_EQ(co_await drain(response), 0); + auto message = co_await session.async_get(url, sent); + EXPECT_EQ(message.result_int(), 200); + expect_contains(message, sent); + EXPECT_THAT(message.body(), IsEmpty()); }; } @@ -130,11 +128,8 @@ TEST_P(Headers, WHEN_header_name_repeats_THEN_all_values_arrive_in_order) }; test = [this, sent, values](Session session) -> awaitable { - auto request = co_await session.async_submit(url, sent); - co_await request.async_write_eof(); - auto response = co_await request.async_get_response(); - EXPECT_THAT(values_of(response.fields(), "x-repeated"), ElementsAreArray(values)); - co_await drain(response); + auto message = co_await session.async_get(url, sent); + EXPECT_THAT(values_of(message, "x-repeated"), ElementsAreArray(values)); }; } @@ -167,10 +162,8 @@ TEST_P(Headers, WHEN_request_headers_exceed_default_limit_THEN_server_responds_4 }; test = [this, sent](Session session) -> awaitable { - auto request = co_await session.async_submit(url, sent); - co_await request.async_write_eof(); - auto response = co_await request.async_get_response(); - EXPECT_EQ(response.status_code(), 431); + auto message = co_await session.async_get(url, sent); + EXPECT_EQ(message.result_int(), 431); }; } @@ -217,17 +210,10 @@ class HeaderLimits : public ClientAsync if (response_size) target.params().set("response_size", std::to_string(response_size)); - auto [ec, request] = co_await session.async_submit(target, sent, as_tuple); - if (!ec) - std::tie(ec) = co_await request.async_write_eof(as_tuple); + auto [ec, message] = co_await session.async_get(target, sent, as_tuple); if (ec) co_return std::unexpected(ec); - - auto [ec2, response] = co_await request.async_get_response(as_tuple); - if (ec2) - co_return std::unexpected(ec2); - co_await drain(response); - co_return response.status_code(); + co_return message.result_int(); } size_t handled = 0;