Boost C++ Libraries

PrevUpHomeNext

Quick Start

This chapter walks through what you need to build against Boost.Taar, how to bring it into your own project, the smallest useful HTTP server you can write with it, and a tour of the idioms you will see again and again:

  • A C++20 conforming compiler. Boost.Taar is tested as C++20, C++23 and C++26 on GCC, Clang, Apple Clang and MSVC toolchains.
  • Boost 1.84 or later, with the Boost.Asio, Boost.Beast, Boost.URL, Boost.JSON, and Boost.System libraries available.
  • CMake 3.28 or later if you build against the bundled CMakeLists.txt.

Taar is header-only. Drop the include/ directory on your include path and link against the compiled Boost libraries that Boost.Asio, Boost.Beast, Boost.URL, Boost.JSON, and Boost.System already require for your application.

With CMake, add the sources to your build with CPM or FetchContent, or install taar (cmake --install) and use the package it installs. Find Boost (1.84 or newer, with the compiled Boost.JSON and Boost.URL) first: taar uses the Boost targets it finds.

find_package(Boost 1.84 CONFIG REQUIRED COMPONENTS json url)

# CPM (after including CPM.cmake)
CPMAddPackage("gh:rjahanbakhshi/boost.taar@1.1.0")

# or FetchContent
include(FetchContent)
FetchContent_Declare(boost_taar
    GIT_REPOSITORY https://github.com/rjahanbakhshi/boost.taar.git
    GIT_TAG v1.1.0)
FetchContent_MakeAvailable(boost_taar)

target_link_libraries(app PRIVATE Boost::taar)

# or, installed (the package finds Boost itself)
find_package(boost-taar CONFIG REQUIRED)
target_link_libraries(app PRIVATE boost-taar::boost-taar)

Tests and examples are only built when taar is the top-level project. Boost::taar names the library in every setup: CPM, FetchContent, the installed package, and the Boost superproject. Programs that use TLS also link OpenSSL (OpenSSL::SSL).

Inside a Boost super-project the library participates in the modular build through build.jam; no extra configuration is required.

The smallest useful program registers a single REST endpoint and runs an acceptor on port 8080:

#include <boost/taar/handler/rest.hpp>
#include <boost/taar/matcher/method.hpp>
#include <boost/taar/matcher/target.hpp>
#include <boost/taar/server/tcp.hpp>
#include <boost/taar/session/http.hpp>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/detached.hpp>
#include <boost/asio/io_context.hpp>
#include <boost/beast/http/verb.hpp>

int main()
{
    namespace taar = boost::taar;
    namespace http = boost::beast::http;
    using taar::matcher::method;
    using taar::matcher::target;

    boost::asio::io_context io;

    taar::session::http http_session;
    http_session.register_request_handler(
        method == http::verb::get && target == "/hello",
        taar::handler::rest([]{ return "Hello, world!"; }));

    taar::cancellation_signals signals;
    boost::asio::co_spawn(
        io,
        taar::server::tcp("0.0.0.0", "8080", http_session, signals),
        boost::asio::detached);

    io.run();
}

The server is spawned on io itself and makes each connection's strand from it; to spawn it anywhere else (on a strand, say), pass the connection executor first, as in taar::server::tcp(io.get_executor(), "0.0.0.0", "8080", http_session, signals) (see The asynchronous model).

For a full version that includes signal handling and graceful shutdown, see the examples chapter.

Stock response
taar::session::http http_session;
http_session.register_request_handler(
    method == http::verb::get && target == "/api/version",
    taar::handler::rest([]{ return "1.0"; }));
JSON response with path arguments
http_session.register_request_handler(
    method == http::verb::get && target == "/api/sum/{a}/{b}",
    taar::handler::rest(
        [](int a, int b)
        {
            return boost::json::value {
                {"a", a},
                {"b", b},
                {"result", a + b}
            };
        },
        taar::handler::path_arg("a"),
        taar::handler::path_arg("b")));
Asynchronous handler

A handler may be a coroutine. Declare its result type as taar::awaitable<T> and co_await other asynchronous work (here a lookup that hits the network or a database) before producing the response. The session awaits the handler, so the connection is never blocked while the work is in flight:

http_session.register_request_handler(
    method == http::verb::get && target == "/api/user/{id}",
    taar::handler::rest(
        [](int id) -> taar::awaitable<boost::json::value>
        {
            auto user = co_await find_user(id);  // co_awaits I/O
            co_return boost::json::value {
                {"id", id},
                {"name", user.name}
            };
        },
        taar::handler::path_arg("id")));

The argument extraction and the response_from conversion of the returned value work exactly as in the synchronous case; only the result type and the co_return change.

Serving static files

handler::htdocs serves a directory: the greedy capture names the file, HEAD is answered from the same route, and validators, conditional requests, ranges and precompressed variants work without configuration.

http_session.register_request_handler(
    method == http::verb::get && target == "/{*path}",
    taar::handler::htdocs {document_root});

htdocs_options tunes it. A typical front end mounts its hashed assets with long-lived caching and serves the application shell for client-side routes:

taar::handler::htdocs_options assets;
assets.root = "./dist/static";
assets.cache_rules = taar::handler::cache_rules::immutable_hashed();
http_session.register_request_handler(
    method == http::verb::get && target == "/static/{*path}",
    taar::handler::htdocs {std::move(assets)});

taar::handler::htdocs_options app;
app.root = "./dist";
app.fallback_document = "index.html";   // /settings/profile -> index.html
http_session.register_request_handler(
    method == http::verb::get && target == "/{*path}",
    taar::handler::htdocs {std::move(app)});
Custom handler
http_session.register_request_handler(
    method == http::verb::put && target == "/special/{*path}",
    [](http::request<http::empty_body> const& request,
       taar::matcher::context const& context)
        -> http::message_generator
    {
        http::response<http::string_body> res {
            http::status::ok, request.version()};
        res.set(http::field::content_type, "text/plain");
        res.keep_alive(request.keep_alive());
        res.body() = "Special path is: " + context.path_args.at("path");
        res.prepare_payload();
        return res;
    });
Streaming request bodies

A handler that takes a body_reader receives the body as it arrives, with no buffering and no body limit; it decides how much to accept. Header, path and query providers combine with it as usual:

http_session.register_request_handler(
    method == http::verb::post && target == "/upload",
    taar::handler::rest(
        [](taar::body_reader& body) -> taar::awaitable<std::string>
        {
            std::uint64_t bytes = 0;
            while (!body.done()) bytes += (co_await body.read_some()).size();
            co_return std::to_string(bytes);
        },
        taar::handler::stream_body_arg {}));

A raw handler can declare taar::streaming_request const& instead; it is the request header plus the reader. If the handler answers before the body is fully read, the response closes the connection.

Forms and file uploads

multipart/form-data has two providers. multipart_form_arg buffers the body (under the session's body limit) and hands over a multipart_form with fields and files; multipart_stream_arg hands over a multipart_reader that walks the parts as they arrive, so a file field of any size is consumed in pieces:

http_session.register_request_handler(
    method == http::verb::post && target == "/profile",
    taar::handler::rest(
        [](taar::multipart_form form)
        {
            auto name = form.value("name").value_or("");
            auto const* avatar = form.find("avatar");
            return std::string {name} + ": " + std::to_string(avatar ? avatar->data.size() : 0);
        },
        taar::handler::multipart_form_arg {}));

http_session.register_request_handler(
    method == http::verb::post && target == "/files",
    taar::handler::rest(
        [](taar::multipart_reader form) -> taar::awaitable<std::string>
        {
            std::uint64_t bytes = 0;
            while (auto part = co_await form.next_part())
                while (!form.part_done()) bytes += (co_await form.read_some()).size();
            co_return std::to_string(bytes);
        },
        taar::handler::multipart_stream_arg {}));
Per-connection state

Give the session a context type and ask for it in a handler. One object is created per TCP connection and shared by every request on it:

struct conn_state { int requests = 0; };

taar::session::basic_http<conn_state> http_session;
http_session.register_request_handler(
    method == http::verb::get && target == "/stats",
    taar::handler::rest([](conn_state& c)
    {
        return boost::json::value {{"requests", ++c.requests}};
    }));
Cookie sessions

Keyed state lives in a context_store; with_session resolves the key from a cookie (or header), creates the entry when allowed, locks it for the request and injects it:

struct user_session { int visits = 0; };
taar::context_store<std::string, user_session> sessions {std::chrono::minutes {30}};

http_session.register_request_handler(
    method == http::verb::get && target == "/",
    taar::handler::with_session(
        sessions,
        taar::handler::session_key::cookie {"sid"},
        taar::handler::session_policy::create_if_missing,
        taar::handler::rest([](user_session& s) { return ++s.visits; })));

With session_policy::require a request without a valid key is answered 401 before the handler runs. The inner handler may also stream (chunked_response, async_generator): the entry is locked only until the stream is produced, and a minted key is set on the stream's headers.

JSON-RPC 2.0

An endpoint owns a registry of methods and hands you one handler for one route. Batches, notifications and error codes are handled for you:

taar::jsonrpc::endpoint<> rpc;
rpc.method("add", [](int a, int b) { return a + b; },
    taar::jsonrpc::rpc_arg("a"), taar::jsonrpc::rpc_arg("b"));

http_session.register_request_handler(
    method == http::verb::post && target == "/rpc", rpc.handler());
MCP

An MCP endpoint composes a JSON-RPC endpoint and adds the protocol's lifecycle, tools, resources and sessions. The tool's inputSchema is derived from the callable's parameter types:

struct notes { std::vector<std::string> items; };
taar::mcp::endpoint<notes> mcp {{.name = "notes", .version = "1.0.0"}};
mcp.tool("notes/add", "Append a note",
    [](notes& n, std::string text) { n.items.push_back(text); },
    taar::mcp::mcp_arg("text", "The note"));

http_session.register_request_handler(
    method == http::verb::get && target == "/mcp", mcp.stream_handler());
http_session.register_request_handler(target == "/mcp", mcp.handler());

Prompts register the same way (mcp.prompt(name, description, callable, mcp_arg...)), mcp.complete_prompt(name, argument, completer) answers completion/complete for an argument, and mcp.resource_updated(uri) notifies the sessions that subscribed to a resource. Passing a taar::mcp::tool_spec instead of name and description adds a title, annotations such as destructive_hint, and an explicit outputSchema (one is derived from the return type otherwise). A tool returning taar::mcp::image::from_bytes(png, "image/png") (or audio, embedded_resource, a full tool_result) emits binary content; a resource returning taar::mcp::blob carries bytes. A stateful tool may ask for taar::mcp::session_info const& to learn its own session id and push a notification to its caller, and REST request providers such as header_arg work on tools and JSON-RPC methods too:

mcp.tool("notes/remind", "Remind the caller",
    [&](taar::mcp::session_info const& me, std::string text)
    {
        return mcp.notify(me.id, "notifications/message", {{"data", text}});
    },
    taar::mcp::mcp_arg("text"));

rpc.method("whoami", [](std::string token) { return token; },
    taar::handler::header_arg(http::field::authorization));

A stateful tool can also ask its client something and wait for the answer: co_await mcp.elicit(me.id, "Delete everything?", schema) sends an elicitation/create request over the client's stream and resumes with its reply (create_message and list_roots work the same way, and request sends any method). Check me.capabilities first; a client that did not declare the capability will not answer.

A tool or method may also declare a jsonrpc::request_scope const&: it carries the request id, reports MCP progress when the client sent a progressToken, and is cancelled by notifications/cancelled (or rpc.cancel(id)), interrupting whatever the callable awaits:

mcp.tool("export", "Long running",
    [](taar::jsonrpc::request_scope const& scope, int items) -> taar::awaitable<int>
    {
        for (int i = 1; i <= items && !scope.cancelled(); ++i)
        {
            co_await step(i);
            scope.report_progress(i, items);
        }
        co_return items;
    },
    taar::mcp::mcp_arg("items"));

The same endpoint serves the stdio transport that desktop clients launch locally:

boost::asio::co_spawn(io, mcp.serve_stdio(), boost::asio::detached);
Errors

Handlers throw ordinary exceptions; the library reports its own errors as taar::failure values, and every error hook receives one. A hook recognises your exception type with f.as<E>() and leaves the rest to the default mapping (argument errors are 400, -32602 or a protocol error; anything unrecognised escalates or becomes an internal error):

struct not_found : std::runtime_error { using std::runtime_error::runtime_error; };

http_session.set_soft_error_handler([](taar::failure const& f)
{
    if (auto const e = f.as<not_found>())
        return taar::response_builder(e->what()).set_status(http::status::not_found);
    return taar::response_builder(f.text()).set_status(taar::http_status_for(f));
});

rpc.set_error_mapper([](taar::failure const& f) -> taar::jsonrpc::error_info
{
    if (auto const e = f.as<not_found>()) return {-32004, e->what(), std::nullopt};
    return taar::jsonrpc::default_error_mapper(f);
});

mcp.set_tool_error_handler([](taar::failure const& f) -> std::optional<std::string>
{
    if (f.as<not_found>()) return "no such note";
    return taar::mcp::default_tool_error_handler(f);
});

http_session.set_hard_error_handler([](taar::failure const& f)
{
    std::cerr << f.chain() << '\n';      // tagged, with the whole cause chain
});

Library code throws taar::exception, whose error() is the failure. See Error mapping per surface for the default mappings.

Authorization

An interceptor checks every request header before routing, before any body is read, and can reject it; what it learns travels to the handler as a request attribute:

struct principal { std::string user; };

http_session.add_interceptor(
    !(method == http::verb::options),            // let CORS preflights through
    [](http::request_header<> const& req, taar::matcher::context& ctx)
    {
        auto const auth = req[http::field::authorization];
        if (!auth.starts_with("Bearer "))
            return taar::reject(http::status::unauthorized);
        ctx.attributes.set(principal {lookup_user(auth.substr(7))});
        return taar::proceed();
    });

http_session.register_request_handler(
    method == http::verb::get && target == "/me",
    taar::handler::rest([](principal const& p) { return p.user; },
        taar::handler::attribute_arg<principal>()));

attribute_arg works the same way in JSON-RPC methods and MCP tools served by the session. For a check on one route only, wrap its handler in taar::handler::guard(check, handler); see Interceptors and guards.

CORS

Browser clients need preflight answers and Access-Control headers on every response. A taar::cors policy provides both:

taar::cors cors;
cors.allowed_origins = {"http://localhost:3000"};

http_session.set_response_filter(cors.response_filter());
http_session.register_request_handler(
    method == http::verb::options, cors.preflight_handler());
OpenAPI

Describe routes as you register them and serve the generated document:

taar::openapi::generator openapi {{.title = "Demo", .version = "1.0.0"}};

http_session.register_request_handler(
    method == http::verb::get && target == "/api/sum/{a}/{b}",
    taar::handler::rest([](int a, int b) { return a + b; },
        taar::handler::path_arg("a", "First addend"),
        taar::handler::path_arg("b", "Second addend")),
    openapi.describe("Add two numbers"));

http_session.register_request_handler(
    method == http::verb::get && target == "/openapi.json", openapi.handler());

Wrap a provider in constrained to validate the value and document the rule in one place; defaults from with_default appear in the schema as well. Security schemes are registered once and inferred from the routes that read them:

openapi.add_security_scheme("bearer", taar::openapi::security_scheme::bearer("JWT"));

http_session.register_request_handler(
    method == http::verb::get && target == "/api/items",
    taar::handler::rest(&list_items,
        taar::handler::constrained(
            taar::handler::with_default(taar::handler::query_arg("page"), 1), {.minimum = 1}),
        taar::handler::header_arg(http::field::authorization)),
    openapi.describe("List items"));   // page: default 1, minimum 1; security: bearer
TLS

HTTPS is the same session over a TLS stream, accepted by server::tls, which performs the handshake before the session starts. Programs using it link OpenSSL.

boost::asio::ssl::context tls {boost::asio::ssl::context::tls_server};
tls.use_certificate_chain_file("cert.pem");
tls.use_private_key_file("key.pem", boost::asio::ssl::context::pem);

taar::session::https https_session;
https_session.register_request_handler(
    method == http::verb::get && target == "/hello",
    taar::handler::rest([] { return "hello over TLS"; }));

boost::asio::co_spawn(
    io,
    taar::server::tls("0.0.0.0", "8443", tls, https_session, signals),
    boost::asio::detached);
Graceful shutdown

Emit the cancellation signals from a signal handler instead of stopping the io_context: the acceptor stops, in-flight requests complete, idle connections close, and io.run() returns:

taar::cancellation_signals signals;
boost::asio::signal_set os_signals {io, SIGINT, SIGTERM};
os_signals.async_wait([&](auto, auto) { signals.emit(); });

boost::asio::co_spawn(
    io,
    taar::server::tcp("0.0.0.0", "8080", http_session, signals),
    taar::ignore_and_rethrow);

The server registers itself and every connection with signals, so emit() may be called from any thread. Running io.run() on several threads is supported: each connection lives on its own strand.

Timeouts

Slow or idle clients are bounded by the session's timeouts (defaults in parentheses; zero disables one). Only time spent waiting on the client counts, never the handler's own:

http_session.set_idle_timeout(std::chrono::seconds {60});    // between requests: close
http_session.set_header_timeout(std::chrono::seconds {30});  // first byte to end of header: 408
http_session.set_body_timeout(std::chrono::seconds {60});    // silence while reading a body: 408
http_session.set_send_timeout(std::chrono::seconds {60});    // a blocked write: close

PrevUpHomeNext