![]() |
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:
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.
taar::session::http http_session; http_session.register_request_handler( method == http::verb::get && target == "/api/version", taar::handler::rest([]{ return "1.0"; }));
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")));
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.
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)});
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; });
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.
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 {}));
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}}; }));
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.
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());
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);
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.
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.
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());
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
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);
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.
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