Extending

Teaching Burl to send a type as a request body, or to read a response body into a type, is a matter of writing a tag_invoke overload found by argument-dependent lookup. The built-in conversions for strings, JSON, and files are written exactly this way.

Sending a Type as a Request Body

To make a type usable with the request_builder::body function, provide a tag_invoke overload taking body_from_tag<T>. It returns an any_request_body, a type-erased wrapper around an object satisfying the RequestBody concept:

struct MyRequestBody
{
    std::optional<std::string>   content_type()   const;
    std::optional<std::uint64_t> content_length() const;
    capy::io_task<>              write(capy::any_buffer_sink& sink) const;
};

Here is a complete body that serializes an nlohmann::json document. Because the serialized text is materialized first, its size is known, so the body reports a Content-Length:

burl::any_request_body
tag_invoke(burl::body_from_tag<nlohmann::json>, const nlohmann::json& value)
{
    class json_body
    {
        std::string text_;

    public:
        explicit json_body(const nlohmann::json& value)
            : text_(value.dump())
        {
        }

        std::optional<std::string>
        content_type() const
        {
            return "application/json";
        }

        std::optional<std::uint64_t>
        content_length() const noexcept
        {
            return text_.size();
        }

        capy::io_task<>
        write(capy::any_buffer_sink& sink) const
        {
            auto [ec, n] = co_await sink.write(capy::make_buffer(text_));
            co_return { ec };
        }
    };
    return json_body{ value };
}

Because content_length() returns a size, the request goes out with a Content-Length header; had it returned std::nullopt, the body would be sent with chunked transfer encoding instead. The application/json from content_type() is used unless the request already sets that header explicitly.

The overload is found by argument-dependent lookup, so placing it in the type’s own namespace is enough. The type then works with request_builder::body like any built-in:

auto [ec, r] = co_await client.post("https://example.com/post")
    .body<nlohmann::json>({ { "user", "John" } })
    .send();

Reading a Response Body into a Type

To read a response into a value with as<T> or try_as<T>, provide a tag_invoke overload taking body_to_tag<T> and the response. It returns a capy::io_task<T> that reads the body and converts it:

capy::io_task<nlohmann::json>
tag_invoke(burl::body_to_tag<nlohmann::json>, burl::response& resp)
{
    // Try the parser's in-place buffer first; it is allocation-free
    // when the body fits.
    auto [ec, sv] = co_await resp.try_as_view();

    // Fall back to a heap string when the body is larger than the buffer.
    std::string st;
    if(ec == boost::http::error::in_place_overflow)
    {
        auto [sec, body] = co_await resp.try_as<std::string>();
        ec = sec;
        st = std::move(body);
        sv = st;
    }
    if(ec)
        co_return { ec, {} };

    // Surface a parse failure as an error rather than a discarded value.
    auto doc = nlohmann::json::parse(sv, nullptr, false);
    if(doc.is_discarded())
        co_return { make_error_code(std::errc::bad_message), {} };
    co_return { {}, std::move(doc) };
}

This follows the recommended pattern for reading a body. It first calls try_as_view, which is allocation-free when the body fits the parser’s in-place buffer, and only on http::error::in_place_overflow falls back to reading into a std::string.

With the overload in place, the type is usable with as<T> and try_as<T>:

auto doc = co_await client.get("https://example.com/data")
    .as<nlohmann::json>();

Forwarding Extra Arguments

Both body and as forward any trailing arguments to the matching tag_invoke, positioned after its fixed parameters. An overload can therefore take configuration that Burl knows nothing about.

The built-in file conversion uses this for its destination path. Its overload declares the extra parameter after the response:

capy::io_task<std::filesystem::path>
tag_invoke(
    burl::body_to_tag<std::filesystem::path>,
    burl::response& resp,
    std::filesystem::path dest);

so the trailing argument at the call site lands in dest:

co_await client.get("https://example.com/file")
    .as<std::filesystem::path>("./out.bin");

Next Steps