diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..ba6af77 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,39 @@ +# Copyright 2019 - 2021 Alexander Grund +# Distributed under the Boost Software License, Version 1.0. +# (See accompanying file LICENSE_1_0.txt or copy at http://boost.org/LICENSE_1_0.txt) + +codecov: + max_report_age: off + require_ci_to_pass: yes + notify: + # Increase this if you have multiple coverage collection jobs + after_n_builds: 1 + wait_for_ci: yes + +# Fix paths from CI build to match repository structure. +# The lcov report records absolute paths such as +# /home/runner/.../boost-root/libs/burl/include/... +fixes: + - "boost-root/libs/burl/::" + +# Make coverage checks informational (report but never fail CI) +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true + +# Change how pull request comments look +comment: + layout: "reach,diff,flags,files,footer" + +# Ignore specific files or folders. Glob patterns are supported. +# See https://docs.codecov.com/docs/ignoring-paths +ignore: + - example/* + - example/**/* + - test/* + - test/**/* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c657414..4e46955 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,7 +138,7 @@ jobs: b2-toolset: "gcc" is-latest: true name: "GCC 15: C++20" - shared: true + shared: false # TODO build-type: "Release" build-cmake: true @@ -187,7 +187,7 @@ jobs: b2-toolset: "clang" is-latest: true name: "Clang 20: C++20-23" - shared: true + shared: false # TODO build-type: "Release" build-cmake: true diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml new file mode 100644 index 0000000..df1df4d --- /dev/null +++ b/.github/workflows/code-coverage.yml @@ -0,0 +1,311 @@ +# +# Copyright (c) 2026 Sam Darwin +# Copyright (c) 2026 Alexander Grund +# +# Distributed under the Boost Software License, Version 1.0. (See accompanying +# file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +# +# Official repository: https://github.com/cppalliance/burl/ +# + +# Instructions +# +# After running this workflow successfully, go to https://github.com/cppalliance/burl/settings/pages +# and enable github pages on the code-coverage branch. +# The coverage will be hosted at https://cppalliance.github.io/burl +# + +name: Code Coverage + +on: + push: + branches: + - master + - develop + paths: + - 'src/**' + - 'include/**' + - 'test/**' + - '.github/workflows/code-coverage.yml' + workflow_dispatch: + +concurrency: + group: code-coverage-pages + cancel-in-progress: false + +env: + GIT_FETCH_JOBS: 8 + NET_RETRY_COUNT: 5 + GCOVR_COMMIT_MSG: "Update coverage data" + +jobs: + build-linux: + defaults: + run: + shell: bash + name: Coverage (Linux) + runs-on: ubuntu-24.04 + timeout-minutes: 120 + + steps: + - name: Clone Boost.Burl + uses: actions/checkout@v6 + with: + path: burl-root + + - name: Setup C++ + uses: alandefreitas/cpp-actions/setup-cpp@v1.9.4 + id: setup-cpp + with: + compiler: gcc + version: 13 + check-latest: true + + - name: Install packages + uses: alandefreitas/cpp-actions/package-install@v1.9.4 + with: + apt-get: >- + build-essential libssl-dev zlib1g-dev libbrotli-dev + lcov + + - name: Clone Boost + uses: alandefreitas/cpp-actions/boost-clone@v1.9.4 + id: boost-clone + with: + branch: ${{ (github.ref_name == 'master' && github.ref_name) || 'develop' }} + boost-dir: boost-source + scan-modules-dir: burl-root + patches: > + https://github.com/cppalliance/capy + https://github.com/cppalliance/corosio + https://github.com/cppalliance/http + + - name: ASLR Fix + run: sysctl vm.mmap_rnd_bits=28 + + - name: Patch Boost + id: patch + run: | + set -xe + + # Identify boost module being tested + module=${GITHUB_REPOSITORY#*/} + echo "module=$module" >> $GITHUB_OUTPUT + + # Identify GitHub workspace root + workspace_root=$(echo "$GITHUB_WORKSPACE" | sed 's/\\/\//g') + echo -E "workspace_root=$workspace_root" >> $GITHUB_OUTPUT + + # Remove module from boost-source + rm -r "boost-source/libs/$module" || true + + # Copy cached boost-source to an isolated boost-root + cp -r boost-source boost-root + + # Set boost-root output + cd boost-root + boost_root="$(pwd)" + boost_root=$(echo "$boost_root" | sed 's/\\/\//g') + echo -E "boost_root=$boost_root" >> $GITHUB_OUTPUT + cd .. + + # Patch boost-root with workspace module + cp -r "$workspace_root"/burl-root "boost-root/libs/$module" + + - name: Build with coverage + uses: alandefreitas/cpp-actions/cmake-workflow@v1.9.4 + with: + source-dir: boost-root + build-dir: __build_cmake_test__ + build-type: Debug + build-target: tests + run-tests: true + install-prefix: .local + cxxstd: '20' + cc: ${{ steps.setup-cpp.outputs.cc || 'gcc-13' }} + cxx: ${{ steps.setup-cpp.outputs.cxx || 'g++-13' }} + cxxflags: '--coverage -fprofile-arcs -ftest-coverage' + ccflags: '--coverage -fprofile-arcs -ftest-coverage' + shared: false + cmake-version: '>=3.20' + extra-args: | + -D Boost_VERBOSE=ON + -D BOOST_INCLUDE_LIBRARIES="${{ steps.patch.outputs.module }}" + -D CMAKE_EXPORT_COMPILE_COMMANDS=ON + package: false + package-artifact: false + ref-source-dir: boost-root/libs/burl + + - name: Install Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Install Python packages + run: pip install gcovr + + - name: Checkout ci-automation + uses: actions/checkout@v6 + with: + repository: cppalliance/ci-automation + path: ci-automation + + - name: Generate gcovr report + run: | + set -xe + module=$(basename ${GITHUB_REPOSITORY}) + gcov_tool="gcov-${{ steps.setup-cpp.outputs.version-major }}" + command -v "$gcov_tool" >/dev/null 2>&1 || gcov_tool="gcov" + mkdir -p gcovr + + # First pass: collect raw coverage data into JSON + gcovr \ + --root boost-root \ + --gcov-executable "$gcov_tool" \ + --merge-mode-functions separate \ + --sort uncovered-percent \ + --html-title "${module}" \ + --merge-lines \ + --exclude-unreachable-branches \ + --exclude-throw-branches \ + --exclude '.*/example/.*' \ + --exclude '.*/examples/.*' \ + --filter ".*/${module}/.*" \ + --html --output gcovr/index.html \ + --json-summary-pretty --json-summary gcovr/summary.json \ + --json gcovr/coverage-raw.json \ + boost-root/__build_cmake_test__ + + # Fix paths for repo-relative display + python3 ci-automation/scripts/fix_paths.py \ + gcovr/coverage-raw.json \ + gcovr/coverage-fixed.json \ + --repo "${module}" + + # Create symlinks so gcovr can find source files at repo-relative paths + ln -sfn "boost-root/libs/${module}/include" include 2>/dev/null || true + ln -sfn "boost-root/libs/${module}/src" src 2>/dev/null || true + + # Second pass: generate nested HTML from fixed JSON with custom templates + gcovr \ + -a gcovr/coverage-fixed.json \ + --merge-mode-functions separate \ + --sort uncovered-percent \ + --html-nested \ + --html-template-dir=ci-automation/gcovr-templates/html \ + --html-title "${module}" \ + --merge-lines \ + --exclude-unreachable-branches \ + --exclude-throw-branches \ + --exclude '(^|.*/)test/.*' \ + --exclude '.*/example/.*' \ + --exclude '.*/examples/.*' \ + --html --output gcovr/index.html \ + --json-summary-pretty --json-summary gcovr/summary.json + + - name: Generate sidebar navigation + run: python3 ci-automation/scripts/gcovr_build_tree.py gcovr + + - name: Generate badges + run: python3 ci-automation/scripts/generate_badges.py gcovr --json gcovr/summary.json + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-linux + path: gcovr/ + + deploy: + needs: [build-linux] + if: ${{ !cancelled() }} + defaults: + run: + shell: bash + name: Deploy Coverage + runs-on: ubuntu-24.04 + timeout-minutes: 30 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Check for code-coverage Branch + run: | + set -xe + git config --global user.name cppalliance-bot + git config --global user.email cppalliance-bot@example.com + git fetch origin + if git branch -r | grep origin/code-coverage; then + echo "The code-coverage branch exists. Continuing." + else + echo "The code-coverage branch does not exist. Creating it." + git switch --orphan code-coverage + git commit --allow-empty -m "$GCOVR_COMMIT_MSG" + git push origin code-coverage + git checkout $GITHUB_REF_NAME + fi + + - name: Checkout GitHub pages branch + uses: actions/checkout@v6 + with: + ref: code-coverage + path: gh_pages_dir + + - name: Download coverage artifacts + uses: actions/download-artifact@v4 + with: + pattern: coverage-* + path: coverage-artifacts/ + + - name: Copy coverage results + run: | + set -xe + touch gh_pages_dir/.nojekyll + + mkdir -p "gh_pages_dir/develop" + mkdir -p "gh_pages_dir/master" + + # Remove old single-directory layout (migration from gcovr/ to gcovr-linux/) + rm -rf "gh_pages_dir/${GITHUB_REF_NAME}/gcovr" + + # Copy each platform's results (only if artifact exists) + for platform in linux; do + if [ -d "coverage-artifacts/coverage-${platform}" ]; then + rm -rf "gh_pages_dir/${GITHUB_REF_NAME}/gcovr-${platform}" + cp -rp "coverage-artifacts/coverage-${platform}" \ + "gh_pages_dir/${GITHUB_REF_NAME}/gcovr-${platform}" + fi + done + + # Generate branch index pages + for branch in develop master; do + cat > "gh_pages_dir/${branch}/index.html" << 'HTMLEOF' + + Code Coverage + +

Code Coverage Reports

+ + + + HTMLEOF + done + + # Root index + cat > gh_pages_dir/index.html << 'HTMLEOF' + + + + develop
+ master
+ + + HTMLEOF + + cd gh_pages_dir + git config --global user.name cppalliance-bot + git config --global user.email cppalliance-bot@example.com + git add . + git commit --amend -m "$GCOVR_COMMIT_MSG" + git push -f origin code-coverage diff --git a/example/usage.cpp b/example/usage.cpp index 3af343f..84621fd 100644 --- a/example/usage.cpp +++ b/example/usage.cpp @@ -354,11 +354,11 @@ set_timeouts(corosio::tls_context tls_ctx) burl::client::config cfg; // Connect timeout, including TLS handshake and proxy connect - cfg.pool.connect_timeout = std::chrono::seconds(30); + cfg.connect_timeout = std::chrono::seconds(30); // Per read/write timeout, for detecting unresponsive servers regardless // of the request/response size - cfg.pool.io_timeout = std::chrono::seconds(5); + cfg.io_timeout = std::chrono::seconds(5); // Timeout for the whole operation, including retrieving the response cfg.timeout = std::chrono::seconds(60); @@ -434,8 +434,8 @@ capy::task<> connection_pool(corosio::tls_context tls_ctx) { burl::client::config cfg; - cfg.pool.idle_timeout = std::chrono::seconds(60); - cfg.pool.max_idle_per_host = 10; + cfg.pool_idle_timeout = std::chrono::seconds(60); + cfg.pool_max_idle_per_host = 10; burl::client client(co_await capy::this_coro::executor, tls_ctx, cfg); @@ -456,7 +456,7 @@ use_proxy(corosio::tls_context tls_ctx) { burl::client::config cfg; // SOCKS5 and HTTP proxies are supported - cfg.pool.proxy = urls::url("socks5h://user:pass@localhost:8080"); + cfg.proxy = urls::url("socks5h://user:pass@localhost:8080"); burl::client client(co_await capy::this_coro::executor, tls_ctx, cfg); diff --git a/include/boost/burl.hpp b/include/boost/burl.hpp index dcbcb8b..19959b8 100644 --- a/include/boost/burl.hpp +++ b/include/boost/burl.hpp @@ -13,7 +13,6 @@ #include #include #include -#include #include #include #include diff --git a/include/boost/burl/client.hpp b/include/boost/burl/client.hpp index 4cba22a..7deef4e 100644 --- a/include/boost/burl/client.hpp +++ b/include/boost/burl/client.hpp @@ -10,23 +10,28 @@ #ifndef BOOST_BURL_CLIENT_HPP #define BOOST_BURL_CLIENT_HPP -#include #include #include +#include #include #include #include +#include #include +#include #include #include #include +#include #include #include #include #include +#include #include +#include #include #include @@ -56,8 +61,7 @@ class request_builder; @see @ref request_builder, - @ref response, - @ref connection_pool. + @ref response. */ class client { @@ -206,18 +210,95 @@ class client */ std::optional timeout; - /** Connection pool settings. + /** Timeout for establishing a connection. - Controls connection establishment, - including timeouts, socket options, and - the proxy, along with connection reuse. + Covers name resolution, the TCP + connection, proxy negotiation, and the + TLS handshake. */ - connection_pool::config pool; + clock::duration connect_timeout = std::chrono::seconds(60); + + /** Timeout for individual I/O operations. + + When set, applies to every read and write + performed on a connection, bounding the + time the peer may remain unresponsive + regardless of the message size. + */ + std::optional io_timeout = std::nullopt; + + /** Time an idle pooled connection remains usable. + + Pooled connections which have been idle for + longer than this duration are discarded + instead of being reused. + */ + clock::duration pool_idle_timeout = std::chrono::seconds(90); + + /** Maximum number of idle pooled connections per origin. + + When the limit is reached, additional + connections are closed instead of being + returned to the pool. + */ + std::size_t pool_max_idle_per_host = 10; + + /** Set the `TCP_NODELAY` option on sockets. + + Disables Nagle's algorithm on newly + established connections. + */ + bool tcp_nodelay = true; + + /** The local endpoint to bind sockets to. + */ + corosio::endpoint local_address; + + /** The proxy used for establishing connections. + + Supported proxy schemes are `http`, + `socks5`, and `socks5h`. Credentials in the + userinfo component of the URL are used for + proxy authentication. + + @par Example + @code + cfg.proxy = urls::url("socks5h://user:pass@localhost:8080"); + @endcode + */ + std::optional proxy; + + /** Override connection establishment. + + When set, this function is invoked + instead of the built-in name resolution, + TCP connection, proxy negotiation, and + TLS handshake whenever the pool needs a + new connection. + + Intended for testing and for advanced uses + such as connecting over a pre-established + tunnel or a Unix domain socket. + + @par Example + @code + cfg.connect_handler = + [](urls::url_view) -> capy::io_task + { + auto [a, b] = capy::test::make_stream_pair(); + // drive b from the test; hand a to the client + co_return { {}, capy::any_stream(std::move(a)) }; + }; + @endcode + */ + std::function< + capy::io_task(urls::url_view url)> + connect_handler; }; private: config config_; - connection_pool pool_; + std::shared_ptr pool_; http::fields headers_; burl::cookie_jar cookie_jar_; @@ -255,6 +336,30 @@ class client BOOST_BURL_DECL client(capy::executor_ref exec, corosio::tls_context tls_ctx, config cfg); + /** Copy constructor (deleted). + */ + client(client const&) = delete; + + /** Copy assignment (deleted). + */ + client& + operator=(client const&) = delete; + + /** Move constructor. + + @param other The client to move from. + */ + client(client&& other) = default; + + /** Move assignment. + + @param other The client to move from. + + @return A reference to this client. + */ + client& + operator=(client&& other) = default; + /** Return the default headers. These headers are sent with every request. diff --git a/include/boost/burl/connection_pool.hpp b/include/boost/burl/connection_pool.hpp deleted file mode 100644 index ccff57b..0000000 --- a/include/boost/burl/connection_pool.hpp +++ /dev/null @@ -1,256 +0,0 @@ -// -// Copyright (c) 2026 Mohammad Nejati -// -// Distributed under the Boost Software License, Version 1.0. (See accompanying -// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) -// -// Official repository: https://github.com/cppalliance/burl -// - -#ifndef BOOST_BURL_CONNECTION_POOL_HPP -#define BOOST_BURL_CONNECTION_POOL_HPP - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -namespace boost -{ -namespace burl -{ - -class client; -class response; - -/** A pool of reusable HTTP connections. - - This class establishes connections to origin - servers, directly or through a proxy, and - retains completed keep-alive connections for - reuse. Connections are keyed by origin, that is, - the scheme, host, and port of the URL. Idle - connections are discarded after - @ref config::idle_timeout, and at most - @ref config::max_idle_per_host are retained per - origin. - - Each @ref client owns a connection pool, - configured through @ref client::config::pool. - - This class is a shared handle to the pool state. - Copies share the same underlying pool. - - @see @ref client. -*/ -class connection_pool -{ - friend class client; - friend class response; - -public: - /** Configuration settings for a connection pool. - */ - struct config - { - using clock = std::chrono::steady_clock; - - /** Timeout for establishing a connection. - - Covers name resolution, the TCP - connection, proxy negotiation, and the - TLS handshake. - */ - clock::duration connect_timeout = std::chrono::seconds(60); - - /** Timeout for individual I/O operations. - - When set, applies to every read and - write performed on a connection, - bounding the time the peer may remain - unresponsive regardless of the message - size. - */ - std::optional io_timeout = std::nullopt; - - /** Time an idle connection remains usable. - - Pooled connections which have been idle - for longer than this duration are - discarded instead of being reused. - */ - clock::duration idle_timeout = std::chrono::seconds(90); - - /** Maximum number of idle connections per origin. - - When the limit is reached, additional - connections are closed instead of being - returned to the pool. - */ - std::size_t max_idle_per_host = 10; - - /** Set the `TCP_NODELAY` option on sockets. - - Disables Nagle's algorithm on newly - established connections. - */ - bool tcp_nodelay = true; - - /** The local endpoint to bind sockets to. - */ - corosio::endpoint local_address; - - /** The proxy used for establishing connections. - - Supported proxy schemes are `http`, - `socks5`, and `socks5h`. Credentials in - the userinfo component of the URL are - used for proxy authentication. - - @par Example - @code - cfg.pool.proxy = urls::url("socks5h://user:pass@localhost:8080"); - @endcode - */ - std::optional proxy; - }; - - /** Constructor. - - Constructs a connection pool with a default - configuration. - - @param exec The executor used to perform - asynchronous operations. - - @param tls_ctx The TLS context used for - `https` connections. - */ - connection_pool(capy::executor_ref exec, corosio::tls_context tls_ctx) - : connection_pool(exec, std::move(tls_ctx), config{}) - { - } - - /** Constructor. - - Constructs a connection pool with the - provided configuration. - - @param exec The executor used to perform - asynchronous operations. - - @param tls_ctx The TLS context used for - `https` connections. - - @param cfg The configuration settings. - */ - BOOST_BURL_DECL - connection_pool( - capy::executor_ref exec, - corosio::tls_context tls_ctx, - config cfg); - -private: - class impl; - - class connection - { - public: - virtual capy::io_task - read_some(std::span buffers) = 0; - - virtual capy::io_task - write_some(std::span buffers) = 0; - - virtual capy::io_task<> - shutdown() = 0; - - virtual bool - is_open() = 0; - - virtual ~connection() = default; - }; - - class pooled_connection - { - friend class impl; - friend class response; - - std::unique_ptr conn_; - std::weak_ptr pool_; - std::string key_; - std::optional io_timeout_; - capy::detail::buffer_array<8, false> rba_; // TODO - capy::detail::buffer_array<8, true> wba_; // TODO - - pooled_connection() = default; - - pooled_connection( - std::unique_ptr conn, - std::weak_ptr pool, - std::string key, - std::optional io_timeout = std::nullopt) - : conn_(std::move(conn)) - , pool_(std::move(pool)) - , key_(std::move(key)) - , io_timeout_(io_timeout) - { - } - - public: - template - capy::io_task - read_some(MB buffers) - { - rba_ = buffers; - - if(io_timeout_) - return capy::timeout(conn_->read_some(rba_), *io_timeout_); - return conn_->read_some(rba_); - } - - template - capy::io_task - write_some(CB buffers) - { - wba_ = buffers; - - if(io_timeout_) - return capy::timeout(conn_->write_some(wba_), *io_timeout_); - return conn_->write_some(wba_); - } - - explicit - operator bool() const noexcept - { - return conn_ != nullptr; - } - - BOOST_BURL_DECL - void - return_to_pool(); - }; - - capy::io_task - acquire(urls::url_view url); - - std::shared_ptr impl_; -}; - -} // namespace burl -} // namespace boost - -#endif diff --git a/include/boost/burl/detail/connection_pool.hpp b/include/boost/burl/detail/connection_pool.hpp new file mode 100644 index 0000000..d704507 --- /dev/null +++ b/include/boost/burl/detail/connection_pool.hpp @@ -0,0 +1,134 @@ +// +// Copyright (c) 2026 Mohammad Nejati +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/burl +// + +#ifndef BOOST_BURL_DETAIL_CONNECTION_POOL_HPP +#define BOOST_BURL_DETAIL_CONNECTION_POOL_HPP + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace boost +{ +namespace burl +{ +namespace detail +{ + +class connection_pool; + +class connection +{ + capy::detail::buffer_array<8, false> rba_; // TODO + capy::detail::buffer_array<8, true> wba_; // TODO + +public: + template + capy::io_task + read_some(MB buffers) + { + rba_ = buffers; + return do_read_some(rba_); + } + + template + capy::io_task + write_some(CB buffers) + { + wba_ = buffers; + return do_write_some(wba_); + } + + virtual bool + is_open() const noexcept = 0; + + virtual capy::io_task<> + shutdown() = 0; + + virtual ~connection() = default; + +private: + virtual capy::io_task + do_read_some(std::span buffers) = 0; + + virtual capy::io_task + do_write_some(std::span buffers) = 0; +}; + +class pooled_connection +{ + friend class connection_pool; + + using duration = std::chrono::steady_clock::duration; + + std::unique_ptr conn_; + std::weak_ptr pool_; + std::string key_; + std::optional io_timeout_; + +public: + pooled_connection() = default; + + template + capy::io_task + read_some(MB buffers) + { + if(io_timeout_) + return capy::timeout(conn_->read_some(buffers), *io_timeout_); + return conn_->read_some(buffers); + } + + template + capy::io_task + write_some(CB buffers) + { + if(io_timeout_) + return capy::timeout(conn_->write_some(buffers), *io_timeout_); + return conn_->write_some(buffers); + } + + explicit + operator bool() const noexcept + { + return conn_ != nullptr; + } + + BOOST_BURL_DECL + void + return_to_pool(); + +private: + pooled_connection( + std::unique_ptr conn, + std::weak_ptr pool, + std::string key, + std::optional io_timeout = std::nullopt) + : conn_(std::move(conn)) + , pool_(std::move(pool)) + , key_(std::move(key)) + , io_timeout_(io_timeout) + { + } +}; + +} // namespace detail +} // namespace burl +} // namespace boost + +#endif diff --git a/include/boost/burl/response.hpp b/include/boost/burl/response.hpp index 90dbe2a..258d22e 100644 --- a/include/boost/burl/response.hpp +++ b/include/boost/burl/response.hpp @@ -10,9 +10,9 @@ #ifndef BOOST_BURL_RESPONSE_HPP #define BOOST_BURL_RESPONSE_HPP -#include #include #include +#include #include #include #include @@ -77,13 +77,13 @@ class response using clock = std::chrono::steady_clock; urls::url url_; - connection_pool::pooled_connection conn_; + detail::pooled_connection conn_; http::response_parser parser_; std::optional deadline_; response( urls::url url, - connection_pool::pooled_connection conn, + detail::pooled_connection conn, http::response_parser parser, std::optional deadline); @@ -96,7 +96,16 @@ class response */ response() = default; - /** Constructor. + /** Copy constructor (deleted). + */ + response(response const&) = delete; + + /** Copy assignment (deleted). + */ + response& + operator=(response const&) = delete; + + /** Move constructor. Constructs a response by taking ownership of the contents of another response, including @@ -108,7 +117,7 @@ class response BOOST_BURL_DECL response(response&& other) noexcept; - /** Assignment. + /** Move assignment. Takes ownership of the contents of another response, including the underlying diff --git a/src/client.cpp b/src/client.cpp index 255c88d..40bde72 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -11,12 +11,15 @@ #include #include "detail/base64.hpp" -#include "detail/drain.hpp" -#include "detail/reuse.hpp" +#include "detail/can_reuse_conn.hpp" +#include "detail/connection_pool.hpp" +#include "detail/drain_body.hpp" +#include "detail/redirect.hpp" #include #include #include +#include #include #include #include @@ -41,51 +44,6 @@ namespace burl namespace { -struct is_redirect_result -{ - bool is_redirect = false; - bool need_method_change = false; -}; - -is_redirect_result -is_redirect(http::status status, const client::config& cfg) noexcept -{ - // The specifications do not intend for 301 and 302 - // redirects to change the HTTP method, but most - // user agents do change the method in practice. - switch(status) - { - case http::status::moved_permanently: - return { true, !cfg.post301 }; - case http::status::found: - return { true, !cfg.post302 }; - case http::status::see_other: - return { true, !cfg.post303 }; - case http::status::temporary_redirect: - case http::status::permanent_redirect: - return { true, false }; - default: - return { false, false }; - } -} - -urls::url -redirect_url(http::response_base const& response, const urls::url_view& referer) -{ - auto it = response.find(http::field::location); - if(it != response.end()) - { - auto rs = urls::parse_uri_reference(it->value); - if(rs.has_value()) - { - urls::url url; - urls::resolve(referer, rs.value(), url); - return url; - } - } - return {}; -} - void set_accept_encoding( http::parser_config& parser_cfg, @@ -144,7 +102,9 @@ client::client( corosio::tls_context tls_ctx, config cfg) : config_(cfg) - , pool_(exec, std::move(tls_ctx), cfg.pool) + , pool_( + std::make_shared( + exec, std::move(tls_ctx), cfg)) { // Disable codings whose decoder service is unavailable. auto const& ctx = capy::get_system_context(); @@ -301,7 +261,7 @@ client::execute_impl( headers.set(field::cookie, cookies); } - auto [cec, conn] = co_await pool_.acquire(url); + auto [cec, conn] = co_await pool_->acquire(url); if(cec) co_return { cec, {} }; @@ -347,7 +307,7 @@ client::execute_impl( } auto [is_redirect, need_method_change] = - burl::is_redirect(parser.get().status(), config_); + detail::is_redirect(parser.get().status(), config_); if(!is_redirect || !followlocation) { @@ -364,10 +324,9 @@ client::execute_impl( // Read and discard small bodies so the connection can be reused auto [dec] = co_await capy::timeout( - detail::drain_body(parser, conn, 1024 * 1024), + detail::drain_body(parser, capy::any_stream(&conn), 1024 * 1024), std::chrono::seconds(2)); - - if(detail::can_reuse_conn(parser)) + if(!dec && detail::can_reuse_conn(parser)) conn.return_to_pool(); if(maxredirs-- == 0) @@ -383,7 +342,7 @@ client::execute_impl( } // Prepare the next request to follow the redirect - url = redirect_url(parser.get(), url); + url = detail::resolve_location(parser.get(), url); if(url.empty()) co_return { error::bad_redirect_response, {} }; diff --git a/src/connection_pool.cpp b/src/connection_pool.cpp deleted file mode 100644 index 088fbcc..0000000 --- a/src/connection_pool.cpp +++ /dev/null @@ -1,514 +0,0 @@ -// -// Copyright (c) 2026 Mohammad Nejati -// -// Distributed under the Boost Software License, Version 1.0. (See accompanying -// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) -// -// Official repository: https://github.com/cppalliance/burl -// - -#include -#include - -#include "detail/base64.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -namespace boost -{ -namespace burl -{ - -namespace -{ - -std::string_view -effective_port(const urls::url_view& url) -{ - if(url.has_port()) - return url.port(); - - if(url.scheme() == "https") - return "443"; - - if(url.scheme() == "http") - return "80"; - - if(url.scheme() == "socks5" || url.scheme() == "socks5h") - return "1080"; - - return {}; -} - -std::string -origin(urls::url_view url) -{ - std::string key{ url.scheme() }; - key += "://"; - key += url.encoded_host_and_port(); - return key; -} - -capy::io_task<> -connect_tcp( - corosio::tcp_socket& socket, - capy::executor_ref exec, - const connection_pool::config& cfg, - std::string_view host, - std::string_view port) -{ - corosio::resolver resolver(exec); - auto [rec, eps] = co_await resolver.resolve(host, port); - if(rec) - co_return rec; - - if(auto [cec, ep] = co_await corosio::connect(socket, eps); cec) - co_return cec; - - if(cfg.tcp_nodelay) - socket.set_option(corosio::socket_option::no_delay(true)); - - co_return {}; -} - -capy::io_task<> -connect_http_proxy( - corosio::tcp_socket& socket, - std::string_view target_host, - std::string_view target_port, - urls::url_view proxy) -{ - std::string host_port(target_host); - host_port += ':'; - host_port += target_port; - - http::request req(http::method::connect, host_port); - req.set(http::field::host, host_port); - req.set(http::field::proxy_connection, "keep-alive"); - - if(proxy.has_userinfo()) - { - std::string value = "Basic "; - detail::base64_encode(value, proxy.encoded_userinfo().decode()); - req.set(http::field::proxy_authorization, value); - } - - if(auto [ec, n] = - co_await capy::write(socket, capy::make_buffer(req.buffer())); - ec) - co_return ec; - - auto parser_cfg = http::make_parser_config(http::parser_config{ false }); - http::response_parser parser(parser_cfg); - parser.reset(); - parser.start(); - if(auto [ec] = co_await parser.read_header(socket); ec) - co_return { error::proxy_connect_failed }; - - auto status = parser.get().status(); - if(status == http::status::proxy_authentication_required) - co_return { error::proxy_auth_failed }; - if(to_status_class(status) != http::status_class::successful) - co_return { error::proxy_connect_failed }; - - co_return {}; -} - -capy::io_task<> -connect_socks5_proxy( - corosio::tcp_socket& socket, - std::string_view target_host, - std::string_view target_port, - urls::url_view proxy) -{ - // Greeting: offer username/password auth only when credentials are present. - if(proxy.has_userinfo()) - { - std::uint8_t greeting[4] = { 0x05, 0x02, 0x00, 0x02 }; - auto [ec, n] = - co_await capy::write(socket, capy::make_buffer(greeting)); - if(ec) - co_return ec; - } - else - { - std::uint8_t greeting[3] = { 0x05, 0x01, 0x00 }; - auto [ec, n] = - co_await capy::write(socket, capy::make_buffer(greeting)); - if(ec) - co_return ec; - } - - std::uint8_t greeting_resp[2]; - if(auto [ec, n] = - co_await capy::read(socket, capy::make_buffer(greeting_resp)); - ec) - co_return ec; - - if(greeting_resp[0] != 0x05) - co_return { error::proxy_unsupported_version }; - - switch(greeting_resp[1]) - { - case 0x00: // no authentication required - break; - case 0x02: // username/password (RFC 1929) - { - std::string auth_req; - auth_req.push_back(0x01); // sub-negotiation version - - auto user = proxy.encoded_user(); - auth_req.push_back(static_cast(user.decoded_size())); - user.decode({}, urls::string_token::append_to(auth_req)); - - auto pass = proxy.encoded_password(); - auth_req.push_back(static_cast(pass.decoded_size())); - pass.decode({}, urls::string_token::append_to(auth_req)); - - if(auto [ec, n] = - co_await capy::write(socket, capy::make_buffer(auth_req)); - ec) - co_return ec; - - std::uint8_t auth_resp[2]; - if(auto [ec, n] = - co_await capy::read(socket, capy::make_buffer(auth_resp)); - ec) - co_return ec; - - if(auth_resp[1] != 0x00) - co_return { error::proxy_auth_failed }; - break; - } - default: // no acceptable method (0xFF) or anything unexpected - co_return { error::proxy_auth_failed }; - } - - // connection request - std::string conn_req = { 0x05, 0x01, 0x00, 0x03 }; - - conn_req.push_back(static_cast(target_host.size())); - conn_req.append(target_host); - - auto port = - static_cast(std::stoul(std::string(target_port))); - conn_req.push_back(static_cast((port >> 8) & 0xFF)); - conn_req.push_back(static_cast(port & 0xFF)); - - if(auto [ec, n] = co_await capy::write(socket, capy::make_buffer(conn_req)); - ec) - co_return ec; - - // connection response - std::uint8_t reply_head[5]; - if(auto [ec, n] = - co_await capy::read(socket, capy::make_buffer(reply_head)); - ec) - co_return ec; - - if(reply_head[1] != 0x00) - co_return { error::proxy_connect_failed }; - - std::size_t tail = 0; - switch(reply_head[3]) - { - case 0x01: - tail = 4 + 2 - 1; // ipv4 + port - break; - case 0x03: - tail = reply_head[4] + 2u; // domain name + port - break; - case 0x04: - tail = 16 + 2 - 1; // ipv6 + port - break; - default: - co_return { error::proxy_connect_failed }; - } - - std::string reply_tail; - reply_tail.resize(tail); - if(auto [ec, n] = - co_await capy::read(socket, capy::make_buffer(reply_tail)); - ec) - co_return ec; - - co_return {}; -} - -} // namespace - -class connection_pool::impl - : public std::enable_shared_from_this -{ - struct idle_connection - { - std::unique_ptr conn; - config::clock::time_point idle_since; - }; - - capy::executor_ref exec_; - corosio::tls_context tls_ctx_; - std::unordered_multimap idle_; - config config_; - -public: - impl( - capy::executor_ref exec, - corosio::tls_context tls_ctx, - config cfg) - : exec_(exec) - , tls_ctx_(std::move(tls_ctx)) - , config_(std::move(cfg)) - { - } - - capy::io_task - acquire(urls::url_view url) - { - auto key = origin(url); - auto [it, last] = idle_.equal_range(key); - while(it != last) - { - auto entry = std::move(it->second); - it = idle_.erase(it); - - if(config::clock::now() - entry.idle_since >= config_.idle_timeout) - continue; - - if(!entry.conn->is_open()) - continue; - - co_return { - {}, - { std::move(entry.conn), - weak_from_this(), - std::move(key), - config_.io_timeout } - }; - } - - auto [ec, conn] = - co_await capy::timeout(connect(url), config_.connect_timeout); - if(ec) - co_return { ec, {} }; - - co_return { - {}, - { std::move(conn), - weak_from_this(), - std::move(key), - config_.io_timeout } - }; - } - - void - release(pooled_connection pc) - { - if(!pc.conn_ || !pc.conn_->is_open()) - return; - - if(idle_.count(pc.key_) >= config_.max_idle_per_host) - return; - - idle_.emplace( - std::move(pc.key_), - idle_connection{ std::move(pc.conn_), config::clock::now() }); - } - -private: - class tcp_connection final : public connection - { - corosio::tcp_socket socket_; - - public: - explicit tcp_connection(corosio::tcp_socket socket) - : socket_(std::move(socket)) - { - } - - virtual capy::io_task - read_some(std::span buffers) override - { - co_return co_await socket_.read_some(buffers); - } - - virtual capy::io_task - write_some(std::span buffers) override - { - co_return co_await socket_.write_some(buffers); - } - - capy::io_task<> - shutdown() override - { - socket_.shutdown(corosio::shutdown_both); - co_return {}; - } - - bool - is_open() override - { - return socket_.is_open(); - } - }; - - class tls_connection final : public connection - { - corosio::tcp_socket socket_; - corosio::openssl_stream stream_; - - public: - tls_connection( - corosio::tcp_socket socket, - const corosio::tls_context& ctx) - : socket_(std::move(socket)) - , stream_(&socket_, ctx) - { - } - - virtual capy::io_task - read_some(std::span buffers) override - { - return stream_.read_some(buffers); - } - - virtual capy::io_task - write_some(std::span buffers) override - { - return stream_.write_some(buffers); - } - - capy::io_task<> - handshake() - { - return stream_.handshake(corosio::openssl_stream::client); - } - - capy::io_task<> - shutdown() override - { - return stream_.shutdown(); - } - - bool - is_open() override - { - return socket_.is_open(); - } - }; - - capy::io_task> - connect(urls::url_view url) const - { - auto target_port = effective_port(url); - if(target_port.empty()) - co_return { error::unsupported_url_scheme, {} }; - - corosio::tcp_socket socket(exec_); - - if(config_.proxy) - { - auto const& proxy = *config_.proxy; - auto proxy_port = effective_port(proxy); - if(proxy_port.empty()) - co_return { error::unsupported_proxy_scheme, {} }; - - auto [ec] = co_await connect_tcp( - socket, exec_, config_, proxy.encoded_host(), proxy_port); - if(ec) - co_return { ec, {} }; - - if(proxy.scheme() == "http") - { - auto [ec] = co_await connect_http_proxy( - socket, url.encoded_host(), target_port, proxy); - if(ec) - co_return { ec, {} }; - } - else if(proxy.scheme() == "socks5" || proxy.scheme() == "socks5h") - { - auto [ec] = co_await connect_socks5_proxy( - socket, url.encoded_host(), target_port, proxy); - if(ec) - co_return { ec, {} }; - } - else - { - co_return { error::unsupported_proxy_scheme, {} }; - } - } - else - { - auto [ec] = co_await connect_tcp( - socket, exec_, config_, url.encoded_host(), target_port); - if(ec) - co_return { ec, {} }; - } - - if(url.scheme_id() == urls::scheme::https) - { - auto tls_ctx = tls_ctx_; - tls_ctx.set_hostname(url.encoded_host()); - - auto conn = - std::make_unique(std::move(socket), tls_ctx); - auto [hec] = co_await conn->handshake(); - if(hec) - co_return { hec, {} }; - - co_return { {}, std::move(conn) }; - } - - co_return { {}, std::make_unique(std::move(socket)) }; - } -}; - -connection_pool::connection_pool( - capy::executor_ref exec, - corosio::tls_context tls_ctx, - config cfg) - : impl_( - std::make_shared( - exec, std::move(tls_ctx), std::move(cfg))) -{ -} - -capy::io_task -connection_pool::acquire(urls::url_view url) -{ - return impl_->acquire(url); -} - -void -connection_pool::pooled_connection::return_to_pool() -{ - if(auto pool = pool_.lock()) - pool->release(std::move(*this)); -} - -} // namespace burl -} // namespace boost diff --git a/src/detail/reuse.cpp b/src/detail/can_reuse_conn.cpp similarity index 84% rename from src/detail/reuse.cpp rename to src/detail/can_reuse_conn.cpp index d3ded92..2d953aa 100644 --- a/src/detail/reuse.cpp +++ b/src/detail/can_reuse_conn.cpp @@ -7,7 +7,7 @@ // Official repository: https://github.com/cppalliance/burl // -#include "reuse.hpp" +#include "can_reuse_conn.hpp" namespace boost { @@ -40,7 +40,13 @@ can_reuse_conn(http::response_parser& parser) noexcept } } - return parser.is_complete(); + if(!parser.is_complete()) + return false; + + if(parser.has_buffered_data()) + return false; + + return true; } } // namespace detail diff --git a/src/detail/reuse.hpp b/src/detail/can_reuse_conn.hpp similarity index 83% rename from src/detail/reuse.hpp rename to src/detail/can_reuse_conn.hpp index 36b6e6e..4343d99 100644 --- a/src/detail/reuse.hpp +++ b/src/detail/can_reuse_conn.hpp @@ -7,8 +7,8 @@ // Official repository: https://github.com/cppalliance/burl // -#ifndef BOOST_BURL_SRC_DETAIL_REUSE_HPP -#define BOOST_BURL_SRC_DETAIL_REUSE_HPP +#ifndef BOOST_BURL_SRC_DETAIL_CAN_REUSE_CONN_HPP +#define BOOST_BURL_SRC_DETAIL_CAN_REUSE_CONN_HPP #include diff --git a/src/detail/connection_pool.cpp b/src/detail/connection_pool.cpp new file mode 100644 index 0000000..4592016 --- /dev/null +++ b/src/detail/connection_pool.cpp @@ -0,0 +1,370 @@ +// +// Copyright (c) 2026 Mohammad Nejati +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/burl +// + +#include "connection_pool.hpp" + +#include + +#include "http_tunnel.hpp" +#include "socks5_tunnel.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace boost +{ +namespace burl +{ +namespace detail +{ + +namespace +{ + +std::string_view +effective_port(const urls::url_view& url) +{ + if(url.has_port()) + return url.port(); + + if(url.scheme() == "https") + return "443"; + + if(url.scheme() == "http") + return "80"; + + if(url.scheme() == "socks5" || url.scheme() == "socks5h") + return "1080"; + + return {}; +} + +std::string +origin(urls::url_view url) +{ + std::string key{ url.scheme() }; + key += "://"; + key += url.encoded_host_and_port(); + return key; +} + +capy::io_task<> +connect_tcp( + corosio::tcp_socket& socket, + capy::executor_ref exec, + const client::config& cfg, + std::string_view host, + std::string_view port) +{ + corosio::resolver resolver(exec); + auto [rec, eps] = co_await resolver.resolve(host, port); + if(rec) + co_return rec; + + if(auto [cec, ep] = co_await corosio::connect(socket, eps); cec) + co_return cec; + + if(cfg.tcp_nodelay) + socket.set_option(corosio::socket_option::no_delay(true)); + + co_return {}; +} + +class tcp_connection final : public connection +{ + corosio::tcp_socket socket_; + +public: + explicit tcp_connection(corosio::tcp_socket socket) + : socket_(std::move(socket)) + { + } + + bool + is_open() const noexcept override + { + return socket_.is_open(); + } + + capy::io_task<> + shutdown() override + { + socket_.shutdown(corosio::shutdown_both); + co_return {}; + } + +private: + capy::io_task + do_read_some(std::span buffers) override + { + co_return co_await socket_.read_some(buffers); + } + + capy::io_task + do_write_some(std::span buffers) override + { + co_return co_await socket_.write_some(buffers); + } +}; + +class tls_connection final : public connection +{ + corosio::tcp_socket socket_; + corosio::openssl_stream stream_; + +public: + tls_connection(corosio::tcp_socket socket, const corosio::tls_context& ctx) + : socket_(std::move(socket)) + , stream_(&socket_, ctx) + { + } + + capy::io_task<> + handshake() + { + return stream_.handshake(corosio::openssl_stream::client); + } + + bool + is_open() const noexcept override + { + return socket_.is_open(); + } + + capy::io_task<> + shutdown() override + { + return stream_.shutdown(); + } + +private: + capy::io_task + do_read_some(std::span buffers) override + { + return stream_.read_some(buffers); + } + + capy::io_task + do_write_some(std::span buffers) override + { + return stream_.write_some(buffers); + } +}; + +class stream_connection final : public connection +{ + capy::any_stream stream_; + bool open_ = true; + +public: + explicit stream_connection(capy::any_stream stream) + : stream_(std::move(stream)) + { + } + + bool + is_open() const noexcept override + { + return open_; + } + + capy::io_task<> + shutdown() override + { + open_ = false; + co_return {}; + } + +private: + capy::io_task + do_read_some(std::span buffers) override + { + auto [ec, n] = co_await stream_.read_some(buffers); + if(ec) + open_ = false; + co_return { ec, n }; + } + + capy::io_task + do_write_some(std::span buffers) override + { + auto [ec, n] = co_await stream_.write_some(buffers); + if(ec) + open_ = false; + co_return { ec, n }; + } +}; + +} // namespace + +connection_pool::connection_pool( + capy::executor_ref exec, + corosio::tls_context tls_ctx, + config cfg) + : exec_(exec) + , tls_ctx_(std::move(tls_ctx)) + , config_(std::move(cfg)) +{ +} + +capy::io_task +connection_pool::acquire(urls::url_view url) +{ + auto key = origin(url); + auto [it, last] = idle_.equal_range(key); + while(it != last) + { + auto entry = std::move(it->second); + it = idle_.erase(it); + + if(config::clock::now() - entry.idle_since >= config_.pool_idle_timeout) + continue; + + if(!entry.conn->is_open()) + continue; + + co_return { + {}, + { std::move(entry.conn), + weak_from_this(), + std::move(key), + config_.io_timeout } + }; + } + + auto [ec, conn] = + co_await capy::timeout(connect(url), config_.connect_timeout); + if(ec) + co_return { ec, {} }; + + co_return { + {}, + { std::move(conn), + weak_from_this(), + std::move(key), + config_.io_timeout } + }; +} + +void +connection_pool::release(pooled_connection pc) +{ + if(!pc.conn_ || !pc.conn_->is_open()) + return; + + if(idle_.count(pc.key_) >= config_.pool_max_idle_per_host) + return; + + idle_.emplace( + std::move(pc.key_), + idle_connection{ std::move(pc.conn_), config::clock::now() }); +} + +capy::io_task> +connection_pool::connect(urls::url_view url) const +{ + if(config_.connect_handler) + { + auto [ec, stream] = co_await config_.connect_handler(url); + if(ec) + co_return { ec, {} }; + co_return { + {}, std::make_unique(std::move(stream)) }; + } + + auto target_port = effective_port(url); + if(target_port.empty()) + co_return { error::unsupported_url_scheme, {} }; + + corosio::tcp_socket socket(exec_); + + if(config_.proxy) + { + auto const& proxy = *config_.proxy; + auto proxy_port = effective_port(proxy); + if(proxy_port.empty()) + co_return { error::unsupported_proxy_scheme, {} }; + + auto [ec] = co_await connect_tcp( + socket, exec_, config_, proxy.encoded_host(), proxy_port); + if(ec) + co_return { ec, {} }; + + if(proxy.scheme() == "http") + { + auto [ec] = co_await open_http_tunnel( + capy::any_stream(&socket), + url.encoded_host(), + target_port, + proxy); + if(ec) + co_return { ec, {} }; + } + else if(proxy.scheme() == "socks5" || proxy.scheme() == "socks5h") + { + auto [ec] = co_await open_socks5_tunnel( + capy::any_stream(&socket), + url.encoded_host(), + target_port, + proxy); + if(ec) + co_return { ec, {} }; + } + else + { + co_return { error::unsupported_proxy_scheme, {} }; + } + } + else + { + auto [ec] = co_await connect_tcp( + socket, exec_, config_, url.encoded_host(), target_port); + if(ec) + co_return { ec, {} }; + } + + if(url.scheme_id() == urls::scheme::https) + { + auto tls_ctx = tls_ctx_; + tls_ctx.set_hostname(url.encoded_host()); + + auto conn = + std::make_unique(std::move(socket), tls_ctx); + auto [hec] = co_await conn->handshake(); + if(hec) + co_return { hec, {} }; + + co_return { {}, std::move(conn) }; + } + + co_return { {}, std::make_unique(std::move(socket)) }; +} + +void +pooled_connection::return_to_pool() +{ + if(auto pool = pool_.lock()) + pool->release(std::move(*this)); +} + +} // namespace detail +} // namespace burl +} // namespace boost diff --git a/src/detail/connection_pool.hpp b/src/detail/connection_pool.hpp new file mode 100644 index 0000000..9329793 --- /dev/null +++ b/src/detail/connection_pool.hpp @@ -0,0 +1,69 @@ +// +// Copyright (c) 2026 Mohammad Nejati +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/burl +// + +#ifndef BOOST_BURL_SRC_DETAIL_CONNECTION_POOL_HPP +#define BOOST_BURL_SRC_DETAIL_CONNECTION_POOL_HPP + +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace boost +{ +namespace burl +{ +namespace detail +{ + +class connection_pool + : public std::enable_shared_from_this +{ + using config = client::config; + + struct idle_connection + { + std::unique_ptr conn; + config::clock::time_point idle_since; + }; + + capy::executor_ref exec_; + corosio::tls_context tls_ctx_; + std::unordered_multimap idle_; + config config_; + +public: + connection_pool( + capy::executor_ref exec, + corosio::tls_context tls_ctx, + config cfg); + + capy::io_task + acquire(urls::url_view url); + + void + release(pooled_connection pc); + +private: + capy::io_task> + connect(urls::url_view url) const; +}; + +} // namespace detail +} // namespace burl +} // namespace boost + +#endif diff --git a/src/detail/drain.hpp b/src/detail/drain_body.cpp similarity index 77% rename from src/detail/drain.hpp rename to src/detail/drain_body.cpp index 40a5ff2..d424813 100644 --- a/src/detail/drain.hpp +++ b/src/detail/drain_body.cpp @@ -7,15 +7,10 @@ // Official repository: https://github.com/cppalliance/burl // -#ifndef BOOST_BURL_SRC_DETAIL_DRAIN_HPP -#define BOOST_BURL_SRC_DETAIL_DRAIN_HPP +#include "drain_body.hpp" #include #include -#include -#include - -#include namespace boost { @@ -24,13 +19,10 @@ namespace burl namespace detail { -/** Read and discard the remaining body. -*/ -template capy::io_task<> drain_body( http::response_parser& parser, - Stream& conn, + capy::any_stream conn, std::uint64_t limit) { auto source = parser.source_for(conn); @@ -54,5 +46,3 @@ drain_body( } // namespace detail } // namespace burl } // namespace boost - -#endif diff --git a/src/detail/drain_body.hpp b/src/detail/drain_body.hpp new file mode 100644 index 0000000..1552c81 --- /dev/null +++ b/src/detail/drain_body.hpp @@ -0,0 +1,38 @@ +// +// Copyright (c) 2026 Mohammad Nejati +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/burl +// + +#ifndef BOOST_BURL_SRC_DETAIL_DRAIN_BODY_HPP +#define BOOST_BURL_SRC_DETAIL_DRAIN_BODY_HPP + +#include +#include +#include + +#include + +namespace boost +{ +namespace burl +{ +namespace detail +{ + +/** Read and discard the remaining body. +*/ +capy::io_task<> +drain_body( + http::response_parser& parser, + capy::any_stream conn, + std::uint64_t limit); + +} // namespace detail +} // namespace burl +} // namespace boost + +#endif diff --git a/src/detail/http_tunnel.cpp b/src/detail/http_tunnel.cpp new file mode 100644 index 0000000..79bc127 --- /dev/null +++ b/src/detail/http_tunnel.cpp @@ -0,0 +1,78 @@ +// +// Copyright (c) 2026 Mohammad Nejati +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/burl +// + +#include "http_tunnel.hpp" + +#include + +#include "base64.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace boost +{ +namespace burl +{ +namespace detail +{ + +capy::io_task<> +open_http_tunnel( + capy::any_stream stream, + std::string_view target_host, + std::string_view target_port, + urls::url_view proxy) +{ + std::string host_port(target_host); + host_port += ':'; + host_port += target_port; + + http::request req(http::method::connect, host_port); + req.set(http::field::host, host_port); + req.set(http::field::proxy_connection, "keep-alive"); + + if(proxy.has_userinfo()) + { + std::string value = "Basic "; + detail::base64_encode(value, proxy.encoded_userinfo().decode()); + req.set(http::field::proxy_authorization, value); + } + + if(auto [ec, n] = + co_await capy::write(stream, capy::make_buffer(req.buffer())); + ec) + co_return ec; + + auto parser_cfg = http::make_parser_config(http::parser_config{ false }); + http::response_parser parser(parser_cfg); + parser.reset(); + parser.start(); + if(auto [ec] = co_await parser.read_header(stream); ec) + co_return { error::proxy_connect_failed }; + + auto status = parser.get().status(); + if(status == http::status::proxy_authentication_required) + co_return { error::proxy_auth_failed }; + if(to_status_class(status) != http::status_class::successful) + co_return { error::proxy_connect_failed }; + + co_return {}; +} + +} // namespace detail +} // namespace burl +} // namespace boost diff --git a/src/detail/http_tunnel.hpp b/src/detail/http_tunnel.hpp new file mode 100644 index 0000000..f92bba6 --- /dev/null +++ b/src/detail/http_tunnel.hpp @@ -0,0 +1,37 @@ +// +// Copyright (c) 2026 Mohammad Nejati +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/burl +// + +#ifndef BOOST_BURL_SRC_DETAIL_HTTP_TUNNEL_HPP +#define BOOST_BURL_SRC_DETAIL_HTTP_TUNNEL_HPP + +#include +#include +#include + +#include + +namespace boost +{ +namespace burl +{ +namespace detail +{ + +capy::io_task<> +open_http_tunnel( + capy::any_stream stream, + std::string_view target_host, + std::string_view target_port, + urls::url_view proxy); + +} // namespace detail +} // namespace burl +} // namespace boost + +#endif diff --git a/src/detail/redirect.cpp b/src/detail/redirect.cpp new file mode 100644 index 0000000..b7c2794 --- /dev/null +++ b/src/detail/redirect.cpp @@ -0,0 +1,71 @@ +// +// Copyright (c) 2026 Mohammad Nejati +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/burl +// + +#include "redirect.hpp" + +#include +#include + +namespace boost +{ +namespace burl +{ +namespace detail +{ + +is_redirect_result +is_redirect( + http::status status, + const client::config& cfg) noexcept +{ + // The specifications do not intend for 301 and 302 + // redirects to change the HTTP method, but most + // user agents do change the method in practice. + switch(status) + { + case http::status::moved_permanently: + return { true, !cfg.post301 }; + case http::status::found: + return { true, !cfg.post302 }; + case http::status::see_other: + return { true, !cfg.post303 }; + case http::status::temporary_redirect: + case http::status::permanent_redirect: + return { true, false }; + default: + return { false, false }; + } +} + +urls::url +resolve_location( + http::response_base const& response, + const urls::url_view& base) +{ + auto it = response.find(http::field::location); + if(it != response.end()) + { + auto rs = urls::parse_uri_reference(it->value); + if(rs.has_value()) + { + urls::url url; + urls::resolve(base, rs.value(), url); + // RFC 9110, Section 10.2.2: a Location without a + // fragment inherits the fragment of the target URI. + if(!url.has_fragment() && base.has_fragment()) + url.set_encoded_fragment(base.encoded_fragment()); + return url; + } + } + return {}; +} + +} // namespace detail +} // namespace burl +} // namespace boost diff --git a/src/detail/redirect.hpp b/src/detail/redirect.hpp new file mode 100644 index 0000000..e50a3f0 --- /dev/null +++ b/src/detail/redirect.hpp @@ -0,0 +1,47 @@ +// +// Copyright (c) 2026 Mohammad Nejati +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/burl +// + +#ifndef BOOST_BURL_SRC_DETAIL_REDIRECT_HPP +#define BOOST_BURL_SRC_DETAIL_REDIRECT_HPP + +#include + +#include +#include +#include +#include + +namespace boost +{ +namespace burl +{ +namespace detail +{ + +struct is_redirect_result +{ + bool is_redirect = false; + bool need_method_change = false; +}; + +is_redirect_result +is_redirect( + http::status status, + const client::config& cfg) noexcept; + +urls::url +resolve_location( + http::response_base const& response, + const urls::url_view& base); + +} // namespace detail +} // namespace burl +} // namespace boost + +#endif diff --git a/src/detail/socks5_tunnel.cpp b/src/detail/socks5_tunnel.cpp new file mode 100644 index 0000000..3ea923f --- /dev/null +++ b/src/detail/socks5_tunnel.cpp @@ -0,0 +1,153 @@ +// +// Copyright (c) 2026 Mohammad Nejati +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/burl +// + +#include "socks5_tunnel.hpp" + +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace boost +{ +namespace burl +{ +namespace detail +{ + +capy::io_task<> +open_socks5_tunnel( + capy::any_stream stream, + std::string_view target_host, + std::string_view target_port, + urls::url_view proxy) +{ + // Greeting: offer username/password auth only when credentials are present. + if(proxy.has_userinfo()) + { + std::uint8_t greeting[4] = { 0x05, 0x02, 0x00, 0x02 }; + auto [ec, n] = + co_await capy::write(stream, capy::make_buffer(greeting)); + if(ec) + co_return ec; + } + else + { + std::uint8_t greeting[3] = { 0x05, 0x01, 0x00 }; + auto [ec, n] = + co_await capy::write(stream, capy::make_buffer(greeting)); + if(ec) + co_return ec; + } + + std::uint8_t greeting_resp[2]; + if(auto [ec, n] = + co_await capy::read(stream, capy::make_buffer(greeting_resp)); + ec) + co_return ec; + + if(greeting_resp[0] != 0x05) + co_return { error::proxy_unsupported_version }; + + switch(greeting_resp[1]) + { + case 0x00: // no authentication required + break; + case 0x02: // username/password (RFC 1929) + { + std::string auth_req; + auth_req.push_back(0x01); // sub-negotiation version + + auto user = proxy.encoded_user(); + auth_req.push_back(static_cast(user.decoded_size())); + user.decode({}, urls::string_token::append_to(auth_req)); + + auto pass = proxy.encoded_password(); + auth_req.push_back(static_cast(pass.decoded_size())); + pass.decode({}, urls::string_token::append_to(auth_req)); + + if(auto [ec, n] = + co_await capy::write(stream, capy::make_buffer(auth_req)); + ec) + co_return ec; + + std::uint8_t auth_resp[2]; + if(auto [ec, n] = + co_await capy::read(stream, capy::make_buffer(auth_resp)); + ec) + co_return ec; + + if(auth_resp[1] != 0x00) + co_return { error::proxy_auth_failed }; + break; + } + default: // no acceptable method (0xFF) or anything unexpected + co_return { error::proxy_auth_failed }; + } + + // connection request + std::string conn_req = { 0x05, 0x01, 0x00, 0x03 }; + + conn_req.push_back(static_cast(target_host.size())); + conn_req.append(target_host); + + auto port = + static_cast(std::stoul(std::string(target_port))); + conn_req.push_back(static_cast((port >> 8) & 0xFF)); + conn_req.push_back(static_cast(port & 0xFF)); + + if(auto [ec, n] = co_await capy::write(stream, capy::make_buffer(conn_req)); + ec) + co_return ec; + + // connection response + std::uint8_t reply_head[5]; + if(auto [ec, n] = + co_await capy::read(stream, capy::make_buffer(reply_head)); + ec) + co_return ec; + + if(reply_head[1] != 0x00) + co_return { error::proxy_connect_failed }; + + std::size_t tail = 0; + switch(reply_head[3]) + { + case 0x01: + tail = 4 + 2 - 1; // ipv4 + port + break; + case 0x03: + tail = reply_head[4] + 2u; // domain name + port + break; + case 0x04: + tail = 16 + 2 - 1; // ipv6 + port + break; + default: + co_return { error::proxy_connect_failed }; + } + + std::string reply_tail; + reply_tail.resize(tail); + if(auto [ec, n] = + co_await capy::read(stream, capy::make_buffer(reply_tail)); + ec) + co_return ec; + + co_return {}; +} + +} // namespace detail +} // namespace burl +} // namespace boost diff --git a/src/detail/socks5_tunnel.hpp b/src/detail/socks5_tunnel.hpp new file mode 100644 index 0000000..e07c8ad --- /dev/null +++ b/src/detail/socks5_tunnel.hpp @@ -0,0 +1,37 @@ +// +// Copyright (c) 2026 Mohammad Nejati +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/burl +// + +#ifndef BOOST_BURL_SRC_DETAIL_SOCKS5_TUNNEL_HPP +#define BOOST_BURL_SRC_DETAIL_SOCKS5_TUNNEL_HPP + +#include +#include +#include + +#include + +namespace boost +{ +namespace burl +{ +namespace detail +{ + +capy::io_task<> +open_socks5_tunnel( + capy::any_stream stream, + std::string_view target_host, + std::string_view target_port, + urls::url_view proxy); + +} // namespace detail +} // namespace burl +} // namespace boost + +#endif diff --git a/src/response.cpp b/src/response.cpp index d03a393..b75a90a 100644 --- a/src/response.cpp +++ b/src/response.cpp @@ -7,10 +7,9 @@ // Official repository: https://github.com/cppalliance/burl // -#include #include -#include "detail/reuse.hpp" +#include "detail/can_reuse_conn.hpp" #include #include @@ -25,7 +24,7 @@ namespace burl response::response( urls::url url, - connection_pool::pooled_connection conn, + detail::pooled_connection conn, http::response_parser parser, std::optional deadline) : url_(std::move(url)) diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt index 9d064ef..70ecbdd 100644 --- a/test/unit/CMakeLists.txt +++ b/test/unit/CMakeLists.txt @@ -24,6 +24,14 @@ target_link_libraries( boost_capy_test_suite_main Boost::burl) +if (TARGET Boost::http_zlib) + target_link_libraries(boost_burl_tests PRIVATE Boost::http_zlib) +endif() + +if (TARGET Boost::http_brotli) + target_link_libraries(boost_burl_tests PRIVATE Boost::http_brotli) +endif() + target_include_directories(boost_burl_tests PRIVATE . ../../) # Register individual tests with CTest diff --git a/test/unit/body_test.hpp b/test/unit/body_test.hpp index 3443a79..1906e73 100644 --- a/test/unit/body_test.hpp +++ b/test/unit/body_test.hpp @@ -71,33 +71,31 @@ check_body( std::string_view expected) { BOOST_TEST(body.has_value()); - - BOOST_TEST( - capy::test::fuse().armed( - [&](capy::test::fuse& f) -> capy::task - { - capy::test::buffer_sink bs(f); - capy::any_buffer_sink sink(&bs); - - auto [ec] = co_await body.write(sink); - if(ec) - co_return; - - BOOST_TEST_EQ(bs.data(), expected); - })); + capy::test::fuse f; + auto r = f.armed([&](capy::test::fuse& f) -> capy::task<> { + capy::test::buffer_sink bs(f); + capy::any_buffer_sink sink(&bs); + + auto [ec] = co_await body.write(sink); + if(ec) + co_return; + + BOOST_TEST_EQ(bs.data(), expected); + }); + BOOST_TEST(r.success); } inline std::error_code -drive_body( +check_io_body( any_request_body const& body, - capy::any_buffer_sink& sink) + capy::test::buffer_sink& bs) { corosio::io_context ioc; std::error_code ret; - + capy::any_buffer_sink sink(&bs); capy::run_async( ioc.get_executor(), - [&](capy::io_result<> res) { ret = res.ec; })(body.write(sink)); + [&](capy::io_result<> res) {ret = res.ec; })(body.write(sink)); ioc.run(); return ret; } diff --git a/test/unit/client.cpp b/test/unit/client.cpp new file mode 100644 index 0000000..ff45cee --- /dev/null +++ b/test/unit/client.cpp @@ -0,0 +1,1008 @@ +// +// Copyright (c) 2026 Mohammad Nejati +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/burl +// + +// Test that header file is self-contained. +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_suite.hpp" + +#include +#include +#include +#include +#include + +namespace boost +{ +namespace burl +{ + +using namespace std::chrono_literals; + +class client_test +{ + struct fake_net + { + capy::test::fuse fuse; + std::vector scripts; + std::vector closes; // close srv after providing script N + std::deque servers; + std::vector origins; + + fake_net() = default; + + explicit fake_net(capy::test::fuse f) + : fuse(std::move(f)) + { + } + + client::config + config() + { + client::config cfg; + cfg.brotli = false; + cfg.deflate = false; + cfg.gzip = false; + cfg.connect_handler = + [this](urls::url_view url) -> capy::io_task + { + origins.emplace_back(url.encoded_origin()); + auto [cli, srv] = capy::test::make_stream_pair(fuse); + auto const n = servers.size(); + if(n < scripts.size() && !scripts[n].empty()) + srv.provide(scripts[n]); + if(n < closes.size() && closes[n]) + srv.close(); + servers.push_back(std::move(srv)); + co_return { {}, capy::any_stream(std::move(cli)) }; + }; + return cfg; + } + + std::size_t + connects() const noexcept + { + return servers.size(); + } + + capy::test::stream& + server(std::size_t i) + { + return servers.at(i); + } + + std::string + written(std::size_t i) + { + return std::string(servers.at(i).data()); + } + }; + +public: + + void + testRequestSerialization() + { + fake_net net; + net.scripts = { + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n" }; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + net.config()); + + auto [ec, r] = co_await c + .get("http://example.com/index.html?x=1") + .send(); + BOOST_TEST(!ec); + BOOST_TEST(r.status() == http::status::ok); + }()); + + BOOST_TEST_EQ( + net.written(0), + "GET /index.html?x=1 HTTP/1.1\r\n" + "Host: example.com\r\n" + "\r\n"); + } + + void + testEmptyPathBecomesSlash() + { + fake_net net; + net.scripts = { + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n" }; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + net.config()); + auto [ec, r] = + co_await c.get("http://example.com").send(); + BOOST_TEST(!ec); + }()); + + BOOST_TEST( + net.written(0).find("GET / HTTP/1.1\r\n") == 0); + // Default port must not appear in Host. + BOOST_TEST( + net.written(0).find("Host: example.com\r\n") != + std::string::npos); + } + + void + testPostBody() + { + fake_net net; + net.scripts = { + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n" }; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + net.config()); + auto [ec, r] = co_await c + .post("http://example.com/submit") + .body("abc") + .send(); + BOOST_TEST(!ec); + }()); + + auto out = net.written(0); + BOOST_TEST( + out.find("POST /submit HTTP/1.1\r\n") == 0); + BOOST_TEST( + out.find("Content-Length: 3\r\n") != std::string::npos); + // Body follows the header block. + BOOST_TEST( + out.find("\r\n\r\nabc") != std::string::npos); + } + + void + testStatusErrorWithReadableBody() + { + fake_net net; + net.scripts = { + "HTTP/1.1 404 Not Found\r\n" + "Content-Length: 9\r\n" + "\r\n" + "not found" }; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + net.config()); + + auto [ec, r] = co_await c + .get("http://example.com/missing") + .send(); + + // Status errors flow as error codes, but the + // response object is still fully usable: the + // caller decides whether to read the error body. + BOOST_TEST(ec == condition::client_error); + BOOST_TEST( + r.status() == http::status::not_found); + + auto [ec2, body] = co_await r.try_as_view(); + BOOST_TEST(!ec2); + BOOST_TEST_EQ(body, "not found"); + }()); + } + + void + testRedirectSameOrigin() + { + fake_net net; + net.scripts = { + // Connection: close forces the second hop onto a + // fresh pair so each script maps to one connection. + "HTTP/1.1 302 Found\r\n" + "Location: /next\r\n" + "Content-Length: 0\r\n" + "Connection: close\r\n" + "\r\n", + "HTTP/1.1 200 OK\r\n" + "Content-Length: 5\r\n" + "\r\n" + "hello" }; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + net.config()); + + auto [ec, r] = co_await c + .get("http://example.com/old") + .send(); + BOOST_TEST(!ec); + BOOST_TEST(r.status() == http::status::ok); + BOOST_TEST_EQ( + r.url().buffer(), "http://example.com/next"); + + auto [ec2, body] = co_await r.try_as_view(); + BOOST_TEST(!ec2); + BOOST_TEST_EQ(body, "hello"); + }()); + + BOOST_TEST_EQ(net.connects(), 2u); + BOOST_TEST( + net.written(1).find("GET /next HTTP/1.1\r\n") == 0); + // autoreferer defaults to on; same-origin hop carries + // the originating URL. + BOOST_TEST( + net.written(1).find( + "Referer: http://example.com/old\r\n") != + std::string::npos); + } + + void + test303MethodChange() + { + fake_net net; + net.scripts = { + "HTTP/1.1 303 See Other\r\n" + "Location: /done\r\n" + "Content-Length: 0\r\n" + "Connection: close\r\n" + "\r\n", + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n" }; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + net.config()); + + auto [ec, r] = co_await c + .post("http://example.com/form") + .body("a=1") + .send(); + BOOST_TEST(!ec); + BOOST_TEST(r.status() == http::status::ok); + }()); + + BOOST_TEST_EQ(net.connects(), 2u); + + // First hop: POST with body. + BOOST_TEST( + net.written(0).find("POST /form HTTP/1.1\r\n") == 0); + + // 303 rewrites to GET and the body and its framing + // headers must be gone. + auto out = net.written(1); + BOOST_TEST(out.find("GET /done HTTP/1.1\r\n") == 0); + BOOST_TEST( + out.find("Content-Length") == std::string::npos); + BOOST_TEST( + out.find("Content-Type") == std::string::npos); + BOOST_TEST(out.find("a=1") == std::string::npos); + } + + void + testCrossOriginDropsAuthorization() + { + fake_net net; + net.scripts = { + "HTTP/1.1 302 Found\r\n" + "Location: http://other.example/x\r\n" + "Content-Length: 0\r\n" + "Connection: close\r\n" + "\r\n", + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n" }; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + net.config()); + + auto [ec, r] = co_await c + .get("http://example.com/login") + .header( + http::field::authorization, + "Bearer sekrit") + .send(); + BOOST_TEST(!ec); + }()); + + BOOST_TEST_EQ(net.connects(), 2u); + BOOST_TEST_EQ( + net.origins[1], "http://other.example"); + + BOOST_TEST( + net.written(0).find("Authorization: Bearer sekrit") != + std::string::npos); + // Credentials must not leak across origins. + BOOST_TEST( + net.written(1).find("Authorization") == + std::string::npos); + } + + void + testUnrestrictedAuthKeepsAuthorization() + { + fake_net net; + net.scripts = { + "HTTP/1.1 302 Found\r\n" + "Location: http://other.example/x\r\n" + "Content-Length: 0\r\n" + "Connection: close\r\n" + "\r\n", + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n" }; + capy::test::run_blocking()( + [&]() -> capy::task<> + { + auto cfg = net.config(); + cfg.unrestricted_auth = true; + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + cfg); + auto [ec, r] = co_await c + .get("http://example.com/login") + .header( + http::field::authorization, + "Bearer sekrit") + .send(); + BOOST_TEST(!ec); + }()); + + BOOST_TEST( + net.written(1).find("Authorization: Bearer sekrit") != + std::string::npos); + } + + void + testTooManyRedirects() + { + fake_net net; + auto const hop = + "HTTP/1.1 302 Found\r\n" + "Location: /again\r\n" + "Content-Length: 0\r\n" + "Connection: close\r\n" + "\r\n"; + net.scripts = { hop, hop, hop }; + capy::test::run_blocking()( + [&]() -> capy::task<> + { + auto cfg = net.config(); + cfg.maxredirs = 2; + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + cfg); + auto [ec, r] = + co_await c.get("http://example.com/").send(); + BOOST_TEST(ec == error::too_many_redirects); + }()); + + // Original request plus maxredirs follows, then stop. + BOOST_TEST_EQ(net.connects(), 3u); + } + + void + testFollowlocationOff() + { + fake_net net; + net.scripts = { + "HTTP/1.1 302 Found\r\n" + "Location: /next\r\n" + "Content-Length: 0\r\n" + "\r\n" }; + capy::test::run_blocking()( + [&]() -> capy::task<> + { + auto cfg = net.config(); + cfg.followlocation = false; + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + cfg); + auto [ec, r] = + co_await c.get("http://example.com/").send(); + // 3xx is not an error condition; the caller + // gets the redirect response itself. + BOOST_TEST(!ec); + BOOST_TEST(r.status() == http::status::found); + }()); + + BOOST_TEST_EQ(net.connects(), 1u); + } + + void + testBadRedirectResponse() + { + fake_net net; + net.scripts = { + // 302 without a usable Location. + "HTTP/1.1 302 Found\r\n" + "Content-Length: 0\r\n" + "\r\n" }; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + net.config()); + auto [ec, r] = + co_await c.get("http://example.com/").send(); + BOOST_TEST(ec == error::bad_redirect_response); + }()); + } + + void + testKeepAliveReuse() + { + fake_net net; + net.scripts = { + "HTTP/1.1 200 OK\r\n" + "Content-Length: 2\r\n" + "\r\n" + "ok" }; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + net.config()); + + { + auto [ec, r] = co_await c + .get("http://example.com/a") + .send(); + BOOST_TEST(!ec); + auto [ec2, body] = co_await r.try_as_view(); + BOOST_TEST(!ec2); + BOOST_TEST_EQ(body, "ok"); + // r destroyed here: complete + keep-alive, + // so the connection returns to the pool. + } + + net.server(0).provide( + "HTTP/1.1 200 OK\r\n" + "Content-Length: 0\r\n" + "\r\n"); + + auto [ec, r] = co_await c + .get("http://example.com/b") + .send(); + BOOST_TEST(!ec); + }()); + + // One dial, two requests over it. + BOOST_TEST_EQ(net.connects(), 1u); + auto out = net.written(0); + BOOST_TEST( + out.find("GET /a HTTP/1.1\r\n") == 0); + BOOST_TEST( + out.find("GET /b HTTP/1.1\r\n") != std::string::npos); + } + + void + testConnectionCloseNoReuse() + { + fake_net net; + net.scripts = { + "HTTP/1.1 200 OK\r\n" + "Content-Length: 2\r\n" + "Connection: close\r\n" + "\r\n" + "ok", + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n" }; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + net.config()); + + { + auto [ec, r] = co_await c + .get("http://example.com/a") + .send(); + BOOST_TEST(!ec); + auto [ec2, body] = co_await r.try_as_view(); + BOOST_TEST(!ec2); + } + + auto [ec, r] = co_await c + .get("http://example.com/b") + .send(); + BOOST_TEST(!ec); + }()); + + BOOST_TEST_EQ(net.connects(), 2u); + } + + void + testUnconsumedBodyNoReuse() + { + fake_net net; + net.scripts = { + "HTTP/1.1 200 OK\r\n" + "Content-Length: 1024\r\n" + "\r\n", // body never arrives in full + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n" }; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + net.config()); + + { + auto [ec, r] = co_await c + .get("http://example.com/a") + .send(); + BOOST_TEST(!ec); + // Drop r with 1024 unread body bytes + // outstanding: dirty, must not be pooled. + } + + auto [ec, r] = co_await c + .get("http://example.com/b") + .send(); + BOOST_TEST(!ec); + }()); + + BOOST_TEST_EQ(net.connects(), 2u); + } + + void + testExcessBodyBytesSingleResponse() + { + fake_net net; + net.scripts = { + "HTTP/1.1 200 OK\r\n" + "Content-Length: 2\r\n" + "\r\n" + "okX" }; // one byte past Content-Length + capy::test::run_blocking()( + [&]() -> capy::task<> + { + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + net.config()); + + auto [ec, r] = co_await c + .get("http://example.com/a") + .send(); + BOOST_TEST(!ec); + + auto [ec2, body] = co_await r.try_as_view(); + BOOST_TEST(!ec2); + BOOST_TEST_EQ(body, "ok"); + }()); + } + + void + testExcessBytesAfterResponse() + { + fake_net net; + net.scripts = { + "HTTP/1.1 200 OK\r\n" + "Content-Length: 2\r\n" + "\r\n" + "ok" + "BOGUS-TRAILING-GARBAGE", + // The next request must go on a fresh connection. + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n" }; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + net.config()); + + { + auto [ec, r] = co_await c + .get("http://example.com/a") + .send(); + BOOST_TEST(!ec); + auto [ec2, body] = co_await r.try_as_view(); + BOOST_TEST(!ec2); + BOOST_TEST_EQ(body, "ok"); + } + + auto [ec, r] = co_await c + .get("http://example.com/b") + .send(); + BOOST_TEST(!ec); + BOOST_TEST(r.status() == http::status::ok); + }()); + + BOOST_TEST_EQ(net.connects(), 2u); + } + + void + testCookieRoundTrip() + { + fake_net net; + net.scripts = { + "HTTP/1.1 200 OK\r\n" + "Set-Cookie: session=abc123; Path=/\r\n" + "Content-Length: 0\r\n" + "Connection: close\r\n" + "\r\n", + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n" }; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + auto cfg = net.config(); + cfg.cookies = true; + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + cfg); + + { + auto [ec, r] = co_await c + .get("http://example.com/set") + .send(); + BOOST_TEST(!ec); + } + + auto [ec, r] = co_await c + .get("http://example.com/get") + .send(); + BOOST_TEST(!ec); + }()); + + BOOST_TEST( + net.written(0).find("Cookie:") == std::string::npos); + BOOST_TEST( + net.written(1).find("Cookie: session=abc123\r\n") != + std::string::npos); + } + + void + testHeadNoBody() + { + fake_net net; + net.scripts = { + "HTTP/1.1 200 OK\r\n" + "Content-Length: 100\r\n" + "\r\n" }; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + net.config()); + + auto [ec, r] = co_await c + .head("http://example.com/file") + .send(); + BOOST_TEST(!ec); + BOOST_TEST( + r.content_length().value_or(0) == 100); + + auto [ec2, body] = co_await r.try_as_view(); + BOOST_TEST(!ec2); + BOOST_TEST(body.empty()); + }()); + + BOOST_TEST( + net.written(0).find("HEAD /file HTTP/1.1\r\n") == 0); + } + + void + testGzipDecode() + { +#ifdef BOOST_HTTP_HAS_ZLIB + http::zlib::install_inflate_service(capy::get_system_context()); +#else + return; +#endif + + // gzip("hello world"), mtime=0. + static char const gz[] = + "\x1f\x8b\x08\x00\x00\x00\x00\x00\x02\xff\xcb\x48" + "\xcd\xc9\xc9\x57\x28\xcf\x2f\xca\x49\x01\x00\x85" + "\x11\x4a\x0d\x0b\x00\x00\x00"; + auto const body = std::string(gz, sizeof(gz) - 1); + + fake_net net; + net.scripts = { std::string( + "HTTP/1.1 200 OK\r\n" + "Content-Encoding: gzip\r\n" + "Content-Length: " + + std::to_string(body.size()) + + "\r\n\r\n" + body) }; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + auto cfg = net.config(); + cfg.gzip = true; + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + cfg); + + auto [ec, r] = co_await c + .get("http://example.com/z") + .send(); + BOOST_TEST(!ec); + + auto [ec2, text] = co_await r.try_as_view(); + BOOST_TEST(!ec2); + BOOST_TEST_EQ(text, "hello world"); + }()); + + BOOST_TEST( + net.written(0).find("Accept-Encoding: gzip\r\n") != + std::string::npos); + } + + void + testBrotliDecode() + { +#ifdef BOOST_HTTP_HAS_BROTLI + http::brotli::install_decode_service(capy::get_system_context()); +#else + return; +#endif + + // brotli("hello world"). + static char const br[] = + "\x0b\x05\x80\x68\x65\x6c\x6c\x6f\x20\x77\x6f\x72" + "\x6c\x64\x03"; + auto const body = std::string(br, sizeof(br) - 1); + + fake_net net; + net.scripts = { std::string( + "HTTP/1.1 200 OK\r\n" + "Content-Encoding: br\r\n" + "Content-Length: " + + std::to_string(body.size()) + + "\r\n\r\n" + body) }; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + auto cfg = net.config(); + cfg.brotli = true; + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + cfg); + + auto [ec, r] = co_await c + .get("http://example.com/z") + .send(); + BOOST_TEST(!ec); + + auto [ec2, text] = co_await r.try_as_view(); + BOOST_TEST(!ec2); + BOOST_TEST_EQ(text, "hello world"); + }()); + + BOOST_TEST( + net.written(0).find("Accept-Encoding: br\r\n") != + std::string::npos); + } + + void + testConnectTimeout() + { + capy::test::run_blocking()( + []() -> capy::task<> + { + client::config cfg; + cfg.brotli = cfg.deflate = cfg.gzip = false; + cfg.connect_timeout = 20ms; + cfg.connect_handler = + [](urls::url_view) -> capy::io_task + { + if(auto [ec] = co_await capy::delay(5s); ec) + { + BOOST_TEST_EQ(ec, capy::error::canceled); + co_return { ec, {} }; + } + + auto [cli, srv] = capy::test::make_stream_pair(); + co_return { {}, capy::any_stream(std::move(cli)) }; + }; + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + cfg); + + auto [ec, r] = co_await c + .get("http://example.com/") + .send(); + BOOST_TEST_EQ(ec, capy::error::timeout); + }()); + } + + void + testStatusErrorThenTransportErrorOnBody() + { + fake_net net; + net.scripts = { + "HTTP/1.1 500 Internal Server Error\r\n" + "Content-Length: 10\r\n" + "\r\n" + "1234" }; + net.closes = { true }; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + net.config()); + + auto [ec, r] = co_await c + .get("http://example.com/err") + .send(); + BOOST_TEST(ec == condition::server_error); + BOOST_TEST(ec.value() == 500); + BOOST_TEST( + r.status() == + http::status::internal_server_error); + + auto [ec2, body] = co_await r.try_as_view(); + BOOST_TEST(ec2); // transport-level truncation + }()); + } + + void + testTransportErrorInjection() + { + capy::test::fuse f; + auto r = f.armed( + [](capy::test::fuse& f) -> capy::task<> + { + fake_net net(f); + net.scripts = { + "HTTP/1.1 200 OK\r\n" + "Content-Length: 5\r\n" + "\r\n" + "hello" }; + + client c( + co_await capy::this_coro::executor, + corosio::tls_context(), + net.config()); + + auto [ec, res] = co_await c + .get("http://example.com/") + .send(); + if(ec) + co_return; + + auto [ec2, body] = co_await res.try_as_view(); + if(ec2) + co_return; + + BOOST_TEST(res.status() == http::status::ok); + BOOST_TEST_EQ(body, "hello"); + BOOST_TEST( + net.written(0).starts_with("GET / HTTP/1.1\r\n")); + }); + BOOST_TEST(r.success); + } + + void + testVerbs() + { + client c( + capy::get_system_context().get_executor(), + corosio::tls_context()); + + urls::url_view url = "http://example.com"; + + auto check = [&](request_builder rb, http::method method) + { + auto req = std::move(rb).build(); + BOOST_TEST(req.method == method); + BOOST_TEST_EQ(req.url.buffer(), url); + }; + + check(c.get(url), http::method::get); + check(c.head(url), http::method::head); + check(c.post(url), http::method::post); + check(c.put(url), http::method::put); + check(c.patch(url), http::method::patch); + check(c.delete_(url), http::method::delete_); + + // generic + check( + c.request(http::method::options, url), + http::method::options); + } + + void + run() + { + testRequestSerialization(); + testEmptyPathBecomesSlash(); + testPostBody(); + testStatusErrorWithReadableBody(); + testRedirectSameOrigin(); + test303MethodChange(); + testCrossOriginDropsAuthorization(); + testUnrestrictedAuthKeepsAuthorization(); + testTooManyRedirects(); + testFollowlocationOff(); + testBadRedirectResponse(); + testKeepAliveReuse(); + testConnectionCloseNoReuse(); + testUnconsumedBodyNoReuse(); + testExcessBodyBytesSingleResponse(); + testExcessBytesAfterResponse(); + testCookieRoundTrip(); + testHeadNoBody(); + testGzipDecode(); + testBrotliDecode(); + testConnectTimeout(); + testStatusErrorThenTransportErrorOnBody(); + testTransportErrorInjection(); + testVerbs(); + } +}; + +TEST_SUITE(client_test, "boost.burl.client"); + +} // namespace burl +} // namespace boost diff --git a/test/unit/detail/base64.cpp b/test/unit/detail/base64.cpp new file mode 100644 index 0000000..ec0a483 --- /dev/null +++ b/test/unit/detail/base64.cpp @@ -0,0 +1,79 @@ +// +// Copyright (c) 2026 Mohammad Nejati +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/burl +// + +// Test that header file is self-contained. +#include "src/detail/base64.hpp" + +#include "test_suite.hpp" + +#include +#include + +namespace boost +{ +namespace burl +{ +namespace detail +{ + +class base64_test +{ + static std::string + encode(std::string_view src) + { + std::string dest; + base64_encode(dest, src); + return dest; + } + +public: + void + testVectors() + { + // RFC 4648 test vectors + BOOST_TEST_EQ(encode(""), ""); + BOOST_TEST_EQ(encode("f"), "Zg=="); + BOOST_TEST_EQ(encode("fo"), "Zm8="); + BOOST_TEST_EQ(encode("foo"), "Zm9v"); + BOOST_TEST_EQ(encode("foob"), "Zm9vYg=="); + BOOST_TEST_EQ(encode("fooba"), "Zm9vYmE="); + BOOST_TEST_EQ(encode("foobar"), "Zm9vYmFy"); + } + + void + testAlphabet() + { + // exercises the '+' (index 62) and '/' (index 63) characters + BOOST_TEST_EQ(encode(std::string_view("\xFB\xFF\xFF", 3)), "+///"); + BOOST_TEST_EQ(encode(std::string_view("\xFF\xFF\xFF", 3)), "////"); + } + + void + testAppends() + { + // The encoding is appended to the destination. + std::string dest = "Basic "; + base64_encode(dest, "user:pass"); + BOOST_TEST_EQ(dest, "Basic dXNlcjpwYXNz"); + } + + void + run() + { + testVectors(); + testAlphabet(); + testAppends(); + } +}; + +TEST_SUITE(base64_test, "boost.burl.detail.base64"); + +} // namespace detail +} // namespace burl +} // namespace boost diff --git a/test/unit/detail/can_reuse_conn.cpp b/test/unit/detail/can_reuse_conn.cpp new file mode 100644 index 0000000..f5e8515 --- /dev/null +++ b/test/unit/detail/can_reuse_conn.cpp @@ -0,0 +1,120 @@ +// +// Copyright (c) 2026 Mohammad Nejati +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/burl +// + +// Test that header file is self-contained. +#include "src/detail/can_reuse_conn.hpp" + +#include "test_suite.hpp" + +#include +#include +#include +#include + +#include + +namespace boost +{ +namespace burl +{ +namespace detail +{ + +class can_reuse_conn_test +{ + static bool + reusable(std::string_view response) + { + auto [client, server] = capy::test::make_stream_pair(); + server.provide(response); + + http::response_parser parser( + http::make_parser_config(http::parser_config{ false })); + bool result = false; + capy::test::run_blocking()( + [&]() -> capy::task<> + { + parser.reset(); + parser.start(); + if(auto [rec] = co_await parser.read_header(client); rec) + co_return; + result = can_reuse_conn(parser); + }()); + return result; + } + +public: + void + testComplete() + { + // keep-alive, body fully buffered and parseable + BOOST_TEST( + reusable("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello")); + } + + void + testConnectionClose() + { + BOOST_TEST(!reusable( + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")); + } + + void + testHttp10() + { + // HTTP/1.0 is not keep-alive by default + BOOST_TEST(!reusable("HTTP/1.0 200 OK\r\nContent-Length: 0\r\n\r\n")); + } + + void + testIncomplete() + { + // keep-alive, but the body never arrives + BOOST_TEST(!reusable("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n")); + } + + void + testBufferedData() + { + BOOST_TEST(!reusable( + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n" + "ok" + "HTTP/1.1 200 OK\r\n")); + + BOOST_TEST(!reusable( + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nokX")); + } + + void + testNoHeader() + { + http::response_parser parser( + http::make_parser_config(http::parser_config{ false })); + parser.reset(); + parser.start(); + BOOST_TEST(!can_reuse_conn(parser)); + } + + void + run() + { + testComplete(); + testConnectionClose(); + testHttp10(); + testIncomplete(); + testBufferedData(); + testNoHeader(); + } +}; + +TEST_SUITE(can_reuse_conn_test, "boost.burl.detail.can_reuse_conn"); + +} // namespace detail +} // namespace burl +} // namespace boost diff --git a/test/unit/detail/connection_pool.cpp b/test/unit/detail/connection_pool.cpp new file mode 100644 index 0000000..821998d --- /dev/null +++ b/test/unit/detail/connection_pool.cpp @@ -0,0 +1,286 @@ +// +// Copyright (c) 2026 Mohammad Nejati +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/burl +// + +// Test that header file is self-contained. +#include "src/detail/connection_pool.hpp" + +#include "test_suite.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace boost +{ +namespace burl +{ +namespace detail +{ + +using namespace std::chrono_literals; + +class connection_pool_test +{ + struct fake_net + { + std::deque servers; + std::vector origins; + + client::config + config() + { + client::config cfg; + cfg.connect_handler = + [this](urls::url_view url) -> capy::io_task + { + origins.emplace_back(url); + auto [cli, srv] = capy::test::make_stream_pair(); + servers.push_back(std::move(srv)); + co_return { {}, capy::any_stream(std::move(cli)) }; + }; + return cfg; + } + + std::size_t + connects() const noexcept + { + return servers.size(); + } + + capy::test::stream& + server(std::size_t i) + { + return servers.at(i); + } + }; + +public: + void + testOriginKeySeparation() + { + fake_net net; + + urls::url_view const urls[4] = + { + "http://example.com", + "http://example.com:8080", + "https://example.com", + "https://a.example.com" + }; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + auto pool = std::make_shared( + co_await capy::this_coro::executor, + corosio::tls_context(), + net.config()); + + for(auto& u : urls) + { + auto [ec, pc] = co_await pool->acquire(u); + BOOST_TEST(!ec); + pool->release(std::move(pc)); + } + }()); + + BOOST_TEST_EQ(net.connects(), 4u); + BOOST_TEST_EQ(net.origins[0], urls[0]); + BOOST_TEST_EQ(net.origins[1], urls[1]); + BOOST_TEST_EQ(net.origins[2], urls[2]); + BOOST_TEST_EQ(net.origins[3], urls[3]); + } + + void + testSameOriginReuses() + { + fake_net net; + urls::url_view url = "http://example.com"; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + auto pool = std::make_shared( + co_await capy::this_coro::executor, + corosio::tls_context(), + net.config()); + + for(int i = 0; i < 3; ++i) + { + auto [ec, pc] = co_await pool->acquire(url); + BOOST_TEST(!ec); + pool->release(std::move(pc)); + } + }()); + + BOOST_TEST_EQ(net.connects(), 1u); + BOOST_TEST_EQ(net.origins[0], url); + } + + void + testMaxIdlePerHost() + { + fake_net net; + urls::url_view url = "http://example.com"; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + auto cfg = net.config(); + cfg.pool_max_idle_per_host = 1; + auto pool = std::make_shared( + co_await capy::this_coro::executor, + corosio::tls_context(), + cfg); + + auto [ec1, pc1] = co_await pool->acquire(url); + BOOST_TEST(!ec1); + auto [ec2, pc2] = co_await pool->acquire(url); + BOOST_TEST(!ec2); + BOOST_TEST_EQ(net.connects(), 2u); + + pool->release(std::move(pc1)); + pool->release(std::move(pc2)); + + auto [ec3, pc3] = co_await pool->acquire(url); + BOOST_TEST(!ec3); + BOOST_TEST_EQ(net.connects(), 2u); + + auto [ec4, pc4] = co_await pool->acquire(url); + BOOST_TEST(!ec4); + BOOST_TEST_EQ(net.connects(), 3u); + }()); + } + + void + testIdleTimeout() + { + fake_net net; + urls::url_view url = "http://example.com"; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + auto cfg = net.config(); + cfg.pool_idle_timeout = 10ms; + auto pool = std::make_shared( + co_await capy::this_coro::executor, + corosio::tls_context(), + cfg); + + { + auto [ec, pc] = co_await pool->acquire(url); + BOOST_TEST(!ec); + pool->release(std::move(pc)); + } + + if(auto [ec] = co_await capy::delay(50ms); ec) + throw std::system_error(ec); + + auto [ec, pc] = co_await pool->acquire(url); + BOOST_TEST(!ec); + }()); + + BOOST_TEST_EQ(net.connects(), 2u); + } + + void + testStaleIdleReuse() + { + fake_net net; + urls::url_view url = "http://example.com"; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + auto pool = std::make_shared( + co_await capy::this_coro::executor, + corosio::tls_context(), + net.config()); + + { + auto [ec, pc] = co_await pool->acquire(url); + BOOST_TEST(!ec); + pool->release(std::move(pc)); + } + + // Server FINs the idle connection. + net.server(0).close(); + + // TODO: pool should detect the dead idle entry + auto [ec, pc] = co_await pool->acquire(url); + BOOST_TEST(!ec); + // BOOST_TEST_EQ(net.connects(), 2u); + }()); + } + + void + testConnectionOutlivesPool() + { + fake_net net; + urls::url_view url = "http://example.com"; + + capy::test::run_blocking()( + [&]() -> capy::task<> + { + auto pool = std::make_shared( + co_await capy::this_coro::executor, + corosio::tls_context(), + net.config()); + + auto [ec, pc] = co_await pool->acquire(url); + BOOST_TEST(!ec); + + pool.reset(); // pool gone; pc's weak_ptr expires + + // The connection is still fully usable. + net.server(0).provide("hello"); + char buf[8]; + auto [rec, n] = co_await pc.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + BOOST_TEST(!rec); + BOOST_TEST_EQ(std::string_view(buf, n), "hello"); + + // Returning to a dead pool is a safe no-op. + pc.return_to_pool(); + }()); + + BOOST_TEST_EQ(net.connects(), 1u); + } + + void + run() + { + testOriginKeySeparation(); + testSameOriginReuses(); + testMaxIdlePerHost(); + testIdleTimeout(); + testStaleIdleReuse(); + testConnectionOutlivesPool(); + } +}; + +TEST_SUITE(connection_pool_test, "boost.burl.detail.connection_pool"); + +} // namespace detail +} // namespace burl +} // namespace boost diff --git a/test/unit/detail/drain_body.cpp b/test/unit/detail/drain_body.cpp new file mode 100644 index 0000000..2e28eb1 --- /dev/null +++ b/test/unit/detail/drain_body.cpp @@ -0,0 +1,97 @@ +// +// Copyright (c) 2026 Mohammad Nejati +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/burl +// + +// Test that header file is self-contained. +#include "src/detail/drain_body.hpp" + +#include "test_suite.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace boost +{ +namespace burl +{ +namespace detail +{ + +class drain_body_test +{ + static std::pair + drain(capy::test::stream& client, std::uint64_t limit) + { + http::response_parser parser( + http::make_parser_config(http::parser_config{ false })); + std::error_code ec; + capy::test::run_blocking()( + [&]() -> capy::task<> + { + parser.reset(); + parser.start(); + if(auto [rec] = co_await parser.read_header(client); rec) + { + ec = rec; + co_return; + } + auto [dec] = + co_await drain_body(parser, capy::any_stream(&client), limit); + ec = dec; + }()); + return { ec, parser.is_complete() }; + } + +public: + void + testContentLength() + { + auto [client, server] = capy::test::make_stream_pair(); + server.provide("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"); + + auto [ec, complete] = drain(client, 1024); + + BOOST_TEST(!ec); + BOOST_TEST(complete); + } + + void + testChunked() + { + auto [client, server] = capy::test::make_stream_pair(); + server.provide( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" + "5\r\nhello\r\n0\r\n\r\n"); + + auto [ec, complete] = drain(client, 1024); + + BOOST_TEST(!ec); + BOOST_TEST(complete); + } + + void + run() + { + testContentLength(); + testChunked(); + } +}; + +TEST_SUITE(drain_body_test, "boost.burl.detail.drain_body"); + +} // namespace detail +} // namespace burl +} // namespace boost diff --git a/test/unit/detail/http_tunnel.cpp b/test/unit/detail/http_tunnel.cpp new file mode 100644 index 0000000..8251ca2 --- /dev/null +++ b/test/unit/detail/http_tunnel.cpp @@ -0,0 +1,168 @@ +// +// Copyright (c) 2026 Mohammad Nejati +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/burl +// + +// Test that header file is self-contained. +#include "src/detail/http_tunnel.hpp" + +#include "test_suite.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace boost +{ +namespace burl +{ +namespace detail +{ + +class http_tunnel_test +{ + static std::error_code + run_tunnel( + capy::test::stream& client, + std::string_view host, + std::string_view port, + urls::url_view proxy) + { + std::error_code ec; + capy::test::run_blocking()( + [&]() -> capy::task<> + { + auto [e] = co_await open_http_tunnel( + capy::any_stream(&client), host, port, proxy); + ec = e; + }()); + return ec; + } + +public: + void + testSuccess() + { + auto [client, server] = capy::test::make_stream_pair(); + server.provide("HTTP/1.1 200 Connection established\r\n\r\n"); + + auto proxy = urls::parse_uri("http://proxy:8080").value(); + auto ec = run_tunnel(client, "example.com", "443", proxy); + + BOOST_TEST(!ec); + + auto req = server.data(); + BOOST_TEST(req.starts_with("CONNECT example.com:443 HTTP/1.1\r\n")); + BOOST_TEST( + req.find("Host: example.com:443\r\n") != std::string_view::npos); + BOOST_TEST( + req.find("Proxy-Connection: keep-alive\r\n") != + std::string_view::npos); + BOOST_TEST( + req.find("Proxy-Authorization:") == std::string_view::npos); + } + + void + testSuccessWithAuth() + { + auto [client, server] = capy::test::make_stream_pair(); + server.provide("HTTP/1.1 200 Connection established\r\n\r\n"); + + auto proxy = urls::parse_uri("http://user:pass@proxy:8080").value(); + auto ec = run_tunnel(client, "example.com", "443", proxy); + + BOOST_TEST(!ec); + // Basic base64("user:pass") + BOOST_TEST( + server.data().find("Proxy-Authorization: Basic dXNlcjpwYXNz\r\n") != + std::string_view::npos); + } + + void + testAuthRequired() + { + auto [client, server] = capy::test::make_stream_pair(); + server.provide("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n"); + + auto proxy = urls::parse_uri("http://proxy:8080").value(); + auto ec = run_tunnel(client, "example.com", "443", proxy); + + BOOST_TEST(ec == error::proxy_auth_failed); + } + + void + testConnectFailed() + { + auto [client, server] = capy::test::make_stream_pair(); + server.provide("HTTP/1.1 403 Forbidden\r\n\r\n"); + + auto proxy = urls::parse_uri("http://proxy:8080").value(); + auto ec = run_tunnel(client, "example.com", "443", proxy); + + BOOST_TEST(ec == error::proxy_connect_failed); + } + + void + testReadError() + { + auto [client, server] = capy::test::make_stream_pair(); + server.close(); // eof before any response + + auto proxy = urls::parse_uri("http://proxy:8080").value(); + auto ec = run_tunnel(client, "example.com", "443", proxy); + + BOOST_TEST(ec == error::proxy_connect_failed); + } + + void + testTransportErrorInjection() + { + capy::test::fuse f; + auto r = f.armed( + [&](capy::test::fuse&) -> capy::task<> + { + auto [client, server] = capy::test::make_stream_pair(f); + server.provide("HTTP/1.1 200 Connection established\r\n\r\n"); + + auto proxy = urls::parse_uri("http://proxy:8080").value(); + + auto [ec] = co_await open_http_tunnel( + capy::any_stream(&client), "example.com", "443", proxy); + if(ec) + co_return; + + BOOST_TEST(server.data().starts_with( + "CONNECT example.com:443 HTTP/1.1\r\n")); + }); + BOOST_TEST(r.success); + } + + void + run() + { + testSuccess(); + testSuccessWithAuth(); + testAuthRequired(); + testConnectFailed(); + testReadError(); + testTransportErrorInjection(); + } +}; + +TEST_SUITE(http_tunnel_test, "boost.burl.detail.http_tunnel"); + +} // namespace detail +} // namespace burl +} // namespace boost diff --git a/test/unit/detail/redirect.cpp b/test/unit/detail/redirect.cpp new file mode 100644 index 0000000..41a58ba --- /dev/null +++ b/test/unit/detail/redirect.cpp @@ -0,0 +1,194 @@ +// +// Copyright (c) 2026 Mohammad Nejati +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/burl +// + +// Test that header file is self-contained. +#include "src/detail/redirect.hpp" + +#include "test_suite.hpp" + +#include +#include +#include +#include +#include + +#include +#include + +namespace boost +{ +namespace burl +{ +namespace detail +{ + +class redirect_test +{ + static std::string + resolve(std::string_view location, urls::url_view base) + { + http::response response; + response.set(http::field::location, location); + return resolve_location(response, base).buffer(); + } + +public: + void + testNonRedirect() + { + client::config cfg; + for(auto status : + { http::status::ok, + http::status::not_found, + http::status::internal_server_error }) + { + auto r = is_redirect(status, cfg); + BOOST_TEST(!r.is_redirect); + BOOST_TEST(!r.need_method_change); + } + } + + void + testKeepMethod() + { + client::config cfg; + for(auto status : + { http::status::temporary_redirect, + http::status::permanent_redirect }) + { + auto r = is_redirect(status, cfg); + BOOST_TEST(r.is_redirect); + BOOST_TEST(!r.need_method_change); + } + } + + void + testChangeMethod() + { + client::config cfg; + for(auto status : + { http::status::moved_permanently, + http::status::found, + http::status::see_other }) + { + auto r = is_redirect(status, cfg); + BOOST_TEST(r.is_redirect); + BOOST_TEST(r.need_method_change); + } + } + + void + testPost301() + { + client::config cfg; + cfg.post301 = true; + + BOOST_TEST( + !is_redirect(http::status::moved_permanently, cfg) + .need_method_change); + BOOST_TEST(is_redirect(http::status::found, cfg).need_method_change); + BOOST_TEST( + is_redirect(http::status::see_other, cfg).need_method_change); + } + + void + testPost302() + { + client::config cfg; + cfg.post302 = true; + + BOOST_TEST( + is_redirect(http::status::moved_permanently, cfg) + .need_method_change); + BOOST_TEST(!is_redirect(http::status::found, cfg).need_method_change); + BOOST_TEST( + is_redirect(http::status::see_other, cfg).need_method_change); + } + + void + testPost303() + { + client::config cfg; + cfg.post303 = true; + + BOOST_TEST( + is_redirect(http::status::moved_permanently, cfg) + .need_method_change); + BOOST_TEST(is_redirect(http::status::found, cfg).need_method_change); + BOOST_TEST( + !is_redirect(http::status::see_other, cfg).need_method_change); + } + + void + testResolveAbsolute() + { + BOOST_TEST_EQ( + resolve("http://b.test/y", "http://a.test/dir/page"), + "http://b.test/y"); + } + + void + testResolveRelative() + { + BOOST_TEST_EQ( + resolve("other", "http://a.test/dir/page"), + "http://a.test/dir/other"); + BOOST_TEST_EQ( + resolve("/abs", "http://a.test/dir/page"), + "http://a.test/abs"); + } + + void + testResolveNoLocation() + { + http::response response; + BOOST_TEST( + resolve_location(response, "http://a.test/").empty()); + } + + void + testResolveInvalid() + { + BOOST_TEST(resolve("h ttp://bad", "http://a.test/").empty()); + } + + void + testResolveFragment() + { + BOOST_TEST_EQ( + resolve("/other", "http://a.test/page#sec"), + "http://a.test/other#sec"); + + BOOST_TEST_EQ( + resolve("/other#own", "http://a.test/page#sec"), + "http://a.test/other#own"); + } + + void + run() + { + testNonRedirect(); + testKeepMethod(); + testChangeMethod(); + testPost301(); + testPost302(); + testPost303(); + testResolveAbsolute(); + testResolveRelative(); + testResolveNoLocation(); + testResolveInvalid(); + testResolveFragment(); + } +}; + +TEST_SUITE(redirect_test, "boost.burl.detail.redirect"); + +} // namespace detail +} // namespace burl +} // namespace boost diff --git a/test/unit/detail/socks5_tunnel.cpp b/test/unit/detail/socks5_tunnel.cpp new file mode 100644 index 0000000..2ef7346 --- /dev/null +++ b/test/unit/detail/socks5_tunnel.cpp @@ -0,0 +1,218 @@ +// +// Copyright (c) 2026 Mohammad Nejati +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/burl +// + +// Test that header file is self-contained. +#include "src/detail/socks5_tunnel.hpp" + +#include "test_suite.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace boost +{ +namespace burl +{ +namespace detail +{ + +class socks5_tunnel_test +{ + static std::string + bytes(std::initializer_list xs) + { + return std::string(xs.begin(), xs.end()); + } + + static std::string + ipv4_reply() + { + return bytes( + { 0x05, 0x00, 0x00, 0x01, 0x7F, 0x00, 0x00, 0x01, 0x00, 0x50 }); + } + + static std::error_code + run_tunnel( + capy::test::stream& client, + std::string_view host, + std::string_view port, + urls::url_view proxy) + { + std::error_code ec; + capy::test::run_blocking()( + [&]() -> capy::task<> + { + auto [e] = co_await open_socks5_tunnel( + capy::any_stream(&client), host, port, proxy); + ec = e; + }()); + return ec; + } + +public: + void + testSuccessNoAuth() + { + auto [client, server] = capy::test::make_stream_pair(); + server.provide(bytes({ 0x05, 0x00 }) + ipv4_reply()); + + auto proxy = urls::parse_uri("socks5://proxy:1080").value(); + auto ec = run_tunnel(client, "example.com", "443", proxy); + + BOOST_TEST(!ec); + + std::string expected = bytes({ 0x05, 0x01, 0x00 }); // greeting, no auth + expected += bytes({ 0x05, 0x01, 0x00, 0x03, 0x0B }); // connect, domain + expected += "example.com"; + expected += bytes({ 0x01, 0xBB }); // port 443 + BOOST_TEST(server.data() == expected); + } + + void + testSuccessWithAuth() + { + auto [client, server] = capy::test::make_stream_pair(); + server.provide( + bytes({ 0x05, 0x02 }) + // method: username/password + bytes({ 0x01, 0x00 }) + // auth granted + ipv4_reply()); + + auto proxy = urls::parse_uri("socks5://user:pass@proxy:1080").value(); + auto ec = run_tunnel(client, "example.com", "443", proxy); + + BOOST_TEST(!ec); + + std::string expected = bytes({ 0x05, 0x02, 0x00, 0x02 }); // greeting + expected += bytes({ 0x01, 0x04 }) + "user"; // auth: ulen, user + expected += bytes({ 0x04 }) + "pass"; // plen, pass + expected += bytes({ 0x05, 0x01, 0x00, 0x03, 0x0B }) + "example.com"; + expected += bytes({ 0x01, 0xBB }); + BOOST_TEST(server.data() == expected); + } + + void + testSuccessDomainReply() + { + auto [client, server] = capy::test::make_stream_pair(); + server.provide( + bytes({ 0x05, 0x00 }) + + bytes({ 0x05, 0x00, 0x00, 0x03, 0x0B }) + // ATYP=domain, len 11 + "example.com" + bytes({ 0x00, 0x50 })); + + auto proxy = urls::parse_uri("socks5://proxy:1080").value(); + auto ec = run_tunnel(client, "example.com", "443", proxy); + + BOOST_TEST(!ec); + } + + void + testUnsupportedVersion() + { + auto [client, server] = capy::test::make_stream_pair(); + server.provide(bytes({ 0x04, 0x00 })); + + auto proxy = urls::parse_uri("socks5://proxy:1080").value(); + auto ec = run_tunnel(client, "example.com", "443", proxy); + + BOOST_TEST(ec == error::proxy_unsupported_version); + } + + void + testAuthFailed() + { + auto [client, server] = capy::test::make_stream_pair(); + server.provide(bytes({ 0x05, 0x02 }) + bytes({ 0x01, 0x01 })); + + auto proxy = urls::parse_uri("socks5://user:pass@proxy:1080").value(); + auto ec = run_tunnel(client, "example.com", "443", proxy); + + BOOST_TEST(ec == error::proxy_auth_failed); + } + + void + testNoAcceptableMethods() + { + auto [client, server] = capy::test::make_stream_pair(); + server.provide(bytes({ 0x05, 0xFF })); + + auto proxy = urls::parse_uri("socks5://proxy:1080").value(); + auto ec = run_tunnel(client, "example.com", "443", proxy); + + BOOST_TEST(ec == error::proxy_auth_failed); + } + + void + testConnectRejected() + { + auto [client, server] = capy::test::make_stream_pair(); + server.provide( + bytes({ 0x05, 0x00 }) + bytes({ 0x05, 0x01, 0x00, 0x01, 0x00 })); + + auto proxy = urls::parse_uri("socks5://proxy:1080").value(); + auto ec = run_tunnel(client, "example.com", "443", proxy); + + BOOST_TEST(ec == error::proxy_connect_failed); + } + + void + testTransportErrorInjection() + { + capy::test::fuse f; + auto r = f.armed( + [&](capy::test::fuse&) -> capy::task<> + { + auto [client, server] = capy::test::make_stream_pair(f); + server.provide(bytes({ 0x05, 0x00 }) + ipv4_reply()); + + auto proxy = urls::parse_uri("socks5://proxy:1080").value(); + + auto [ec] = co_await open_socks5_tunnel( + capy::any_stream(&client), "example.com", "443", proxy); + if(ec) + co_return; + + std::string expected = bytes({ 0x05, 0x01, 0x00 }); + expected += bytes({ 0x05, 0x01, 0x00, 0x03, 0x0B }); + expected += "example.com"; + expected += bytes({ 0x01, 0xBB }); + BOOST_TEST(server.data() == expected); + }); + BOOST_TEST(r.success); + } + + void + run() + { + testSuccessNoAuth(); + testSuccessWithAuth(); + testSuccessDomainReply(); + testUnsupportedVersion(); + testAuthFailed(); + testNoAcceptableMethods(); + testConnectRejected(); + testTransportErrorInjection(); + } +}; + +TEST_SUITE(socks5_tunnel_test, "boost.burl.detail.socks5_tunnel"); + +} // namespace detail +} // namespace burl +} // namespace boost diff --git a/test/unit/file.cpp b/test/unit/file.cpp index 389b38a..a526b7c 100644 --- a/test/unit/file.cpp +++ b/test/unit/file.cpp @@ -47,8 +47,7 @@ struct file_test BOOST_TEST_EQ(cl.value(), contents.size()); capy::test::buffer_sink bs; - capy::any_buffer_sink sink(&bs); - auto ec = drive_body(body, sink); + auto ec = check_io_body(body, bs); BOOST_TEST(!ec); BOOST_TEST_EQ(bs.data(), contents); } diff --git a/test/unit/multipart_form.cpp b/test/unit/multipart_form.cpp index a6c1b2d..095fb9f 100644 --- a/test/unit/multipart_form.cpp +++ b/test/unit/multipart_form.cpp @@ -89,9 +89,7 @@ struct multipart_form_test BOOST_TEST_EQ(cl.value(), expected.size()); capy::test::buffer_sink bs; - capy::any_buffer_sink sink(&bs); - - auto ec = drive_body(body, sink); + auto ec = check_io_body(body, bs); BOOST_TEST(!ec); BOOST_TEST_EQ(bs.data(), expected); } diff --git a/test/unit/request_builder.cpp b/test/unit/request_builder.cpp new file mode 100644 index 0000000..3cdf77f --- /dev/null +++ b/test/unit/request_builder.cpp @@ -0,0 +1,270 @@ +// +// Copyright (c) 2026 Mohammad Nejati +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/burl +// + +// Test that header file is self-contained. +#include + +#include +#include + +#include +#include +#include +#include +#include + +#include "test_suite.hpp" + +#include +#include +#include + +namespace boost +{ +namespace burl +{ + +using namespace std::chrono_literals; + +class request_builder_test +{ + client + make_client() + { + return client( + capy::get_system_context().get_executor(), + corosio::tls_context()); + } + +public: + void + testBuild() + { + auto c = make_client(); + + auto req = c.request( + http::method::put, "http://example.com/path?x=1").build(); + + BOOST_TEST(req.method == http::method::put); + BOOST_TEST_EQ(req.url.buffer(), "http://example.com/path?x=1"); + BOOST_TEST(req.headers.begin() == req.headers.end()); + BOOST_TEST(!req.body.has_value()); + BOOST_TEST(!req.options.timeout.has_value()); + BOOST_TEST(!req.options.followlocation.has_value()); + } + + void + testQuery() + { + auto c = make_client(); + + // A single parameter is appended to a URL without a query. + BOOST_TEST_EQ( + c.get("http://example.com/path") + .query("category", "shoes") + .build() + .url.buffer(), + "http://example.com/path?category=shoes"); + + // Successive calls append, joined with '&'. + BOOST_TEST_EQ( + c.get("http://example.com/path") + .query("category", "shoes") + .query("color", "blue") + .build() + .url.buffer(), + "http://example.com/path?category=shoes&color=blue"); + + // Parameters are appended after any already present in the URL. + BOOST_TEST_EQ( + c.get("http://example.com/path?x=1") + .query("y", "2") + .build() + .url.buffer(), + "http://example.com/path?x=1&y=2"); + + // The key and value are encoded; spaces become '+' and reserved + // characters are percent-encoded. + BOOST_TEST_EQ( + c.get("http://example.com/path") + .query("full name", "a&b") + .build() + .url.buffer(), + "http://example.com/path?full+name=a%26b"); + } + + void + testHeaderField() + { + auto c = make_client(); + + auto req = c.get("http://example.com") + .header(http::field::accept_language, "en") + .build(); + BOOST_TEST(req.headers.exists(http::field::accept_language)); + BOOST_TEST_EQ(req.headers.at(http::field::accept_language), "en"); + + // Setting the same field again replaces the previous value. + auto req2 = c.get("http://example.com") + .header(http::field::accept_language, "en") + .header(http::field::accept_language, "fr") + .build(); + BOOST_TEST_EQ(req2.headers.count(http::field::accept_language), 1u); + BOOST_TEST_EQ(req2.headers.at(http::field::accept_language), "fr"); + } + + void + testHeaderName() + { + auto c = make_client(); + + auto req = c.get("http://example.com") + .header("X-Debug", "1") + .build(); + BOOST_TEST(req.headers.exists("X-Debug")); + BOOST_TEST_EQ(req.headers.at("X-Debug"), "1"); + + // Setting the same name again replaces the previous value. + auto req2 = c.get("http://example.com") + .header("X-Debug", "1") + .header("X-Debug", "2") + .build(); + BOOST_TEST_EQ(req2.headers.count("X-Debug"), 1u); + BOOST_TEST_EQ(req2.headers.at("X-Debug"), "2"); + } + + void + testBasicAuth() + { + auto c = make_client(); + + auto req = c.get("http://example.com") + .basic_auth("user", "pass") + .build(); + BOOST_TEST_EQ( + req.headers.at(http::field::authorization), + "Basic dXNlcjpwYXNz"); + } + + void + testBearerAuth() + { + auto c = make_client(); + + auto req = c.get("http://example.com") + .bearer_auth("sekrit") + .build(); + BOOST_TEST_EQ( + req.headers.at(http::field::authorization), + "Bearer sekrit"); + } + + void + testTimeout() + { + auto c = make_client(); + + auto req = c.get("http://example.com") + .timeout(5s) + .build(); + BOOST_TEST(req.options.timeout.has_value()); + BOOST_TEST(req.options.timeout.value() == 5s); + } + + void + testFollowlocation() + { + auto c = make_client(); + + auto req = c.get("http://example.com") + .followlocation(false) + .build(); + BOOST_TEST(req.options.followlocation.has_value()); + BOOST_TEST_EQ(req.options.followlocation.value(), false); + + auto req2 = c.get("http://example.com") + .followlocation(true) + .build(); + BOOST_TEST(req2.options.followlocation.has_value()); + BOOST_TEST_EQ(req2.options.followlocation.value(), true); + } + + void + testBody() + { + auto c = make_client(); + + // The value is converted to a body via tag_invoke. + auto req = c.post("http://example.com") + .body(std::string("payload")) + .build(); + BOOST_TEST(req.body.has_value()); + auto ct = req.body.content_type(); + BOOST_TEST(ct.has_value()); + BOOST_TEST_EQ(ct.value(), "text/plain; charset=utf-8"); + auto cl = req.body.content_length(); + BOOST_TEST(cl.has_value()); + BOOST_TEST_EQ(cl.value(), 7u); + + // The any_request_body overload takes ownership of an existing body. + auto body = + tag_invoke(body_from_tag{}, std::string("other")); + auto req2 = c.post("http://example.com") + .body(std::move(body)) + .build(); + BOOST_TEST(req2.body.has_value()); + BOOST_TEST(req2.body.content_length().value() == 5u); + } + + void + testChaining() + { + auto c = make_client(); + + auto req = c.post("http://example.com/post") + .query("category", "shoes") + .header(http::field::accept, "application/json") + .header("X-Debug", "1") + .bearer_auth("sekrit") + .timeout(30s) + .followlocation(false) + .body(std::string("payload")) + .build(); + + BOOST_TEST(req.method == http::method::post); + BOOST_TEST_EQ(req.url.buffer(), "http://example.com/post?category=shoes"); + BOOST_TEST_EQ(req.headers.at(http::field::accept), "application/json"); + BOOST_TEST_EQ(req.headers.at("X-Debug"), "1"); + BOOST_TEST_EQ( + req.headers.at(http::field::authorization), "Bearer sekrit"); + BOOST_TEST(req.options.timeout.value() == 30s); + BOOST_TEST_EQ(req.options.followlocation.value(), false); + BOOST_TEST(req.body.has_value()); + } + + void + run() + { + testBuild(); + testQuery(); + testHeaderField(); + testHeaderName(); + testBasicAuth(); + testBearerAuth(); + testTimeout(); + testFollowlocation(); + testBody(); + testChaining(); + } +}; + +TEST_SUITE(request_builder_test, "boost.burl.request_builder"); + +} // namespace burl +} // namespace boost