Route Handlers
A route handler is the smallest unit of server logic. It is a single function that receives an HTTP request, decides what to do, and tells the server what happened. Every page your server renders, every API endpoint it exposes, every access check it performs — each begins life as a route handler.
Understanding route handlers thoroughly is the foundation for everything that follows. Once you know what a handler is, how it communicates with the server, and what tools it has at its disposal, the rest of the framework clicks into place.
The Signature
A route handler is a coroutine with this signature:
route_task my_handler(route_params& rp);
Three things to notice:
-
It returns
route_task. -
It takes a single parameter: a reference to
route_params. -
It is a coroutine — the body uses
co_returnand may useco_await.
That is the entire contract. If you can write a function with this shape, you can handle HTTP requests. Everything else is details about what you do inside the function.
Why a Coroutine?
Handling an HTTP request often involves waiting: reading the request body from the network, querying a database, writing a response back over the wire. In a traditional blocking server, each of these waits ties up an OS thread. A server handling thousands of simultaneous connections would need thousands of threads, which is expensive and does not scale.
Coroutines solve this elegantly. When a handler reaches a point where it must wait — say, to send the response body over the network — it suspends instead of blocking. The thread is free to run other handlers. When the I/O completes, the handler resumes exactly where it left off, with all its local variables intact.
The result is code that reads like straightforward sequential logic:
route_task greet(route_params& rp)
{
rp.status(status::ok);
auto [ec] = co_await rp.send("Hello, World!");
if(ec)
co_return route_error(ec);
co_return route_done;
}
No callbacks. No state machines. No manual continuation passing. The
co_await on rp.send(…) suspends the coroutine while the response
bytes travel over the network, then resumes it to check the result.
co_return delivers the final directive to the router.
route_task is defined as capy::task<route_result> — a coroutine
task type that produces a route_result when it completes. You never
construct a route_task directly; the compiler creates one when you
write a function body containing co_return or co_await.
The Return Value
When a handler finishes, it must tell the router what happened. It does
this by returning (via co_return) a route_result. There are four
predefined directive constants, plus the ability to return an error:
| Directive | Meaning |
|---|---|
|
The handler completed successfully. A response was sent (or prepared to be sent). The request is finished. |
|
The handler is declining this request. The router should continue and try the next handler in the chain. |
|
Skip all remaining handlers on the current route and jump to the next matching route entirely. |
|
Close the connection immediately. No response is sent. |
And for errors:
co_return route_error(ec); // from a system::error_code
These five outcomes cover every situation a handler can encounter. Most
handlers end with either route_done (I handled it) or route_next
(not my problem, pass it along).
The Pipeline Metaphor
Think of handlers as stations on an assembly line. A request enters
the line and passes through each station in order. Each station
inspects the request and decides: "I’ll handle this" (route_done),
"not for me, next station please" (route_next), or "skip ahead"
(route_next_route). The first station that claims the request
finishes it.
This is a powerful pattern. It means you can decompose complex request
processing into small, focused functions. An authentication handler
checks credentials and returns route_next if valid, or sends a 401
and returns route_done if not. A logging handler records the request
and always returns route_next. A business-logic handler does the
real work and returns route_done.
route_task log_request(route_params& rp)
{
std::cout << rp.req.method_text()
<< " " << rp.url.encoded_path() << "\n";
co_return route_next;
}
route_task require_auth(route_params& rp)
{
if(! rp.req.exists(field::authorization))
{
rp.status(status::unauthorized);
co_await rp.send("Authentication required");
co_return route_done;
}
co_return route_next;
}
route_task serve_dashboard(route_params& rp)
{
rp.status(status::ok);
co_await rp.send("<html><body>Dashboard</body></html>");
co_return route_done;
}
These three handlers, chained together on the same route, give you
logging, access control, and content delivery — each in a clean,
testable function. The router runs them in order and stops at the
first route_done.
The route_params Object
The single parameter to every handler, route_params, is the handler’s
entire world. It holds the incoming request, the outgoing response,
captured URL parameters, I/O channels for the body, and general-purpose
storage. Rather than passing a dozen arguments, the framework bundles
everything into one coherent object.
Let’s walk through each field.
req — The Request
http::request req;
The parsed HTTP request. This is where you find out what the client wants. The most important operations:
// The HTTP method
auto m = rp.req.method(); // http::method::get, post, etc.
auto s = rp.req.method_text(); // "GET", "POST", etc.
// The request target
auto t = rp.req.target(); // "/api/users?page=2"
// HTTP version
auto v = rp.req.version(); // http::version::http_1_1
// Header fields
auto host = rp.req.at(field::host); // throws if missing
bool has_auth = rp.req.exists(field::authorization);
auto ct = rp.req.find(field::content_type); // iterator, or end()
// Connection semantics
bool ka = rp.req.keep_alive(); // persistent connection?
bool chunked = rp.req.chunked(); // chunked transfer encoding?
The request object inherits from fields_base, giving you full access
to iterate, search, and inspect every header field. Headers are stored
in a flat buffer that can be examined with range-for:
for(auto const& f : rp.req)
std::cout << f.name_string() << ": " << f.value() << "\n";
The is_method convenience function on route_params provides a quick
way to test the method without accessing req directly:
if(rp.is_method(method::post))
{
// handle POST
}
res — The Response
http::response res;
The response you are building. When the handler finishes, the server serializes this object and sends it to the client. You control every aspect of it:
// Status code (also available via the convenience method below)
rp.res.set_status(status::ok);
rp.res.set_status(status::not_found);
// Response headers
rp.res.set(field::content_type, "application/json");
rp.res.set(field::cache_control, "max-age=3600");
rp.res.append(field::set_cookie, "session=abc123; HttpOnly");
// Connection semantics
rp.res.set_keep_alive(true);
Like req, the response inherits from fields_base, so the same
field manipulation methods apply. You can set() (replace), append()
(add another), erase(), and exists() on any header.
url — The Parsed URL
urls::url_view url;
The complete request target, parsed into its components by Boost.URL.
While req.target() gives you the raw string, url gives you
structured access:
auto path = rp.url.encoded_path(); // "/api/users"
auto query = rp.url.encoded_query(); // "page=2&sort=name"
bool has_q = rp.url.has_query(); // true
This is especially useful for extracting query parameters or inspecting path segments without manual string parsing.
path and base_path
core::string_view path;
core::string_view base_path;
When routers are nested (a common pattern for organizing large
applications), the URL path is split into two parts. base_path is
the portion that was consumed by the parent router to reach this
handler. path is the remaining portion that this handler should
consider.
For a request to /api/v2/users/42 handled by a sub-router mounted
at /api/v2:
-
base_path="/api/v2" -
path="/users/42"
If there is no nesting, base_path is empty and path is the full
decoded path.
params — Captured Parameters
std::vector<std::pair<std::string, std::string>> params;
When the route pattern contains named parameters (:id) or wildcards
(*path), the router extracts the matched text and stores it here as
name-value pairs:
// For pattern "/users/:id/posts/:pid"
// matching URL "/users/42/posts/7"
for(auto const& [name, value] : rp.params)
std::cout << name << " = " << value << "\n";
// Output:
// id = 42
// pid = 7
Parameters are stored in the order they appear in the pattern. You can search by name with a simple loop, or use them positionally if your pattern is fixed:
route_task show_user(route_params& rp)
{
// Find by name
for(auto const& [name, value] : rp.params)
{
if(name == "id")
{
rp.status(status::ok);
co_await rp.send("User: " + value);
co_return route_done;
}
}
rp.status(status::bad_request);
co_await rp.send("Missing user id");
co_return route_done;
}
req_body — Reading the Request Body
capy::any_buffer_source req_body;
Not every request has a body, but POST and PUT requests typically
do. The req_body field is a type-erased buffer source that lets you
read the incoming body data asynchronously. The body may arrive over
the network in chunks, and req_body abstracts this away.
The source supports two interfaces:
-
BufferSource (
pull/consume): the source provides you with filled buffers that you consume at your own pace. -
ReadSource (
read_some/read): you provide a buffer and the source fills it.
For most handlers, the simplest approach is to read the entire body into a string using a dynamic buffer:
route_task handle_upload(route_params& rp)
{
std::string body;
for(;;)
{
auto [ec, bufs] = co_await rp.req_body.pull();
if(ec)
co_return route_error(ec);
if(bufs.empty())
break; // end of body
for(auto buf : bufs)
body.append(
static_cast<char const*>(buf.data()),
buf.size());
rp.req_body.consume(bufs.size());
}
// body now contains the complete request body
rp.status(status::ok);
co_await rp.send("Received " +
std::to_string(body.size()) + " bytes");
co_return route_done;
}
res_body — Writing the Response Body
capy::any_buffer_sink res_body;
The counterpart to req_body. This is a type-erased buffer sink
connected to the network. When you write data to it, the bytes travel
to the client.
You can write to res_body directly for fine-grained control over
streaming:
route_task stream_data(route_params& rp)
{
rp.status(status::ok);
rp.res.set(field::content_type, "text/plain");
// Stream chunks incrementally
auto [ec1, n1] = co_await rp.res_body.write(
capy::make_buffer("chunk one\n"));
if(ec1)
co_return route_error(ec1);
auto [ec2, n2] = co_await rp.res_body.write(
capy::make_buffer("chunk two\n"));
if(ec2)
co_return route_error(ec2);
// Signal end of body
co_await rp.res_body.write_eof();
co_return route_done;
}
For most cases, however, you won’t touch res_body directly. The
send() convenience method (described next) handles everything.
route_data and session_data
http::datastore route_data;
http::datastore session_data;
These are type-erased containers for storing arbitrary objects. They
use a polystore under the hood, which lets you insert and retrieve
objects by type:
struct user_info
{
std::string name;
int role;
};
// In an authentication middleware:
route_task auth_middleware(route_params& rp)
{
auto token = rp.req.at(field::authorization);
auto user = validate_token(token);
rp.route_data.emplace<user_info>(user.name, user.role);
co_return route_next;
}
// In a later handler:
route_task admin_panel(route_params& rp)
{
auto& user = rp.route_data.get<user_info>();
if(user.role < 1)
{
rp.status(status::forbidden);
co_await rp.send("Admins only");
co_return route_done;
}
rp.status(status::ok);
co_await rp.send("Welcome, " + user.name);
co_return route_done;
}
route_data is scoped to the current request — it is cleared between
requests. session_data persists across requests on the same
connection, making it suitable for per-connection state like
authentication tokens or connection metadata.
The status() Method
route_params& status(http::status code);
A convenience method that sets the response status code. It returns
a reference to route_params, so you can chain it:
rp.status(status::ok);
rp.status(status::not_found);
rp.status(status::internal_server_error);
This is equivalent to rp.res.set_status(code) but shorter in the
common case. Use whichever you prefer — both modify the same response
object.
The send() Method
capy::io_task<> send(std::string_view body = {});
This is where the real magic happens. send() is an asynchronous
operation that takes a string body (or no body at all) and transmits
the complete response to the client. But it does far more than raw I/O.
Here is what send() does automatically:
-
Status-specific behavior. For
204 No Contentand304 Not Modified, it stripsContent-Type,Content-Length, andTransfer-Encodingheaders and sends no body, regardless of what you passed. For205 Reset Content, it setsContent-Length: 0and sends no body. The HTTP specification requires this, andsend()enforces it so you don’t have to remember. -
Content-Type inference. If you haven’t set
Content-Typeyourself,send()guesses: if the body starts with<, it setstext/html; charset=utf-8; otherwisetext/plain; charset=utf-8. You can always override this by setting the header before callingsend(). -
ETag generation. If no
ETagheader is present,send()computes one from the body content. This enables conditional request handling without any effort on your part. -
Freshness checking. After setting the ETag,
send()checks the request’sIf-None-MatchandIf-Modified-Sinceheaders against the response. If the client already has a fresh copy, the response is automatically replaced with304 Not Modifiedand the body is not sent. Your handler doesn’t need to know about conditional requests at all. -
HEAD request handling. If the request method is
HEAD, the response headers are sent but the body is omitted. Again, automatic. -
Body transmission. Finally, the body bytes are written to the response body sink and the stream is finalized.
Because send() is asynchronous, you must co_await it:
route_task my_handler(route_params& rp)
{
rp.status(status::ok);
auto [ec] = co_await rp.send("Hello!");
if(ec)
co_return route_error(ec);
co_return route_done;
}
The return type is io_task<>, which yields a single error_code
through structured bindings. If the send succeeds, ec is
non-failing. If the client disconnected or a write error occurred,
ec describes the failure and you can propagate it with
route_error(ec).
When to Use send() vs. res_body Directly
Use send() when you have the entire response body available as a
string. It handles content negotiation, ETags, conditional responses,
and HEAD requests for you.
Use res_body directly when:
-
You need to stream data incrementally (the body is too large to hold in memory, or it is generated over time)
-
You need fine-grained control over chunked encoding
-
The response body comes from a non-string source (a file, a generator, another stream)
For the overwhelming majority of API handlers, send() is the right
choice.
Error Handlers
Handlers sometimes encounter errors: a database connection fails, a
file cannot be read, an upstream service is down. When a handler
returns route_error(ec), the router switches into error dispatch
mode. Instead of trying the next regular handler, it searches for an
error handler.
An error handler has a different signature than a regular handler. It takes two parameters:
route_task my_error_handler(
route_params& rp,
system::error_code ec)
{
rp.status(status::internal_server_error);
rp.res.set(field::content_type, "text/plain");
co_await rp.send("Something went wrong: " + ec.message());
co_return route_done;
}
The second parameter is the error_code that caused the failure. The
router detected the error handler’s arity automatically — there is no
special registration method. You install error handlers the same way
you install middleware, using use():
// Global error handler
router.use(my_error_handler);
// Error handler scoped to /api routes
router.use("/api",
[](route_params& rp, system::error_code ec)
-> route_task
{
rp.status(status::internal_server_error);
rp.res.set(field::content_type, "application/json");
co_await rp.send(
"{\"error\":\"" + ec.message() + "\"}");
co_return route_done;
});
The router distinguishes regular handlers from error handlers by
inspecting their signature at compile time. A callable that accepts
(route_params&) is a regular handler. A callable that accepts
(route_params&, system::error_code) is an error handler. They
can coexist in the same use() call.
Error Propagation
Error handlers form their own chain. If an error handler returns
route_next, the router continues to the next error handler.
This lets you layer error handling the same way you layer regular
processing:
// Log the error, then pass it along
router.use(
[](route_params& rp, system::error_code ec)
-> route_task
{
std::cerr << "Error: " << ec.message() << "\n";
co_return route_next; // let another handler respond
});
// Send a user-facing response
router.use(
[](route_params& rp, system::error_code ec)
-> route_task
{
rp.status(status::internal_server_error);
co_await rp.send("Internal Server Error");
co_return route_done;
});
The first error handler logs, then yields to the second one, which
sends the response. Clean separation. If an error handler returns
route_done, error dispatch stops and the response is sent.
Path-Scoped Error Handlers
Because error handlers are registered with use(), they can be scoped
to path prefixes. An API might return JSON error responses while a
web frontend returns HTML:
router.use("/api",
[](route_params& rp, system::error_code ec)
-> route_task
{
rp.status(status::internal_server_error);
rp.res.set(field::content_type, "application/json");
co_await rp.send(
"{\"error\":\"" + ec.message() + "\"}");
co_return route_done;
});
router.use(
[](route_params& rp, system::error_code ec)
-> route_task
{
rp.status(status::internal_server_error);
co_await rp.send(
"<html><body><h1>Error</h1><p>"
+ ec.message() + "</p></body></html>");
co_return route_done;
});
Errors from /api routes hit the JSON handler first. Errors from
other routes fall through to the HTML handler. The router’s path
matching works identically for error handlers and regular handlers.
Exception Handlers
Error codes are the preferred error mechanism in Boost.Http, but exceptions can still occur — from third-party libraries, from standard library operations, or from user code. When an exception escapes a handler, the router catches it and switches into exception dispatch mode.
Exception handlers have their own distinct signature:
route_task my_exception_handler(
route_params& rp,
std::exception_ptr ep)
{
try
{
std::rethrow_exception(ep);
}
catch(std::exception const& e)
{
rp.status(status::internal_server_error);
co_await rp.send(e.what());
}
catch(...)
{
rp.status(status::internal_server_error);
co_await rp.send("Unknown error");
}
co_return route_done;
}
The second parameter is a std::exception_ptr carrying the caught
exception. To inspect it, use the standard rethrow_exception /
catch pattern.
Exception handlers are registered with except(), not use():
router.except(my_exception_handler);
// Or with a path prefix
router.except("/api",
[](route_params& rp, std::exception_ptr ep)
-> route_task
{
try
{
std::rethrow_exception(ep);
}
catch(std::exception const& e)
{
rp.status(status::internal_server_error);
rp.res.set(field::content_type,
"application/json");
co_await rp.send(
"{\"error\":\"" +
std::string(e.what()) + "\"}");
}
co_return route_done;
});
The dedicated except() method exists because exception handlers and
regular handlers serve fundamentally different purposes. Error handlers
deal with anticipated failures represented by error codes — network
timeouts, missing files, invalid input. Exception handlers are the
safety net for the unexpected. Keeping them separate makes intent
explicit and prevents accidental interactions.
Like error handlers, exception handlers chain. Return route_next to
defer to the next exception handler. Return route_done to finalize
the response.
Putting It Together
You now know the anatomy of a route handler: its signature, its return
values, every field of route_params, and the three kinds of handlers
(regular, error, exception). Here is a handler that ties several
concepts together:
route_task create_post(route_params& rp)
{
// Read the request body
std::string body;
for(;;)
{
auto [ec, bufs] = co_await rp.req_body.pull();
if(ec)
co_return route_error(ec);
if(bufs.empty())
break;
for(auto buf : bufs)
body.append(
static_cast<char const*>(buf.data()),
buf.size());
rp.req_body.consume(bufs.size());
}
// Validate
if(body.empty())
{
rp.status(status::bad_request);
co_await rp.send("Body required");
co_return route_done;
}
// Persist (hypothetical)
auto post_id = save_to_database(body);
// Respond
rp.status(status::created);
rp.res.set(field::location,
"/posts/" + std::to_string(post_id));
co_await rp.send("{\"id\":" +
std::to_string(post_id) + "}");
co_return route_done;
}
This handler reads a body, validates it, saves it, sets the Location
header for the newly created resource, and returns JSON. The send()
call handles Content-Type, ETag, conditional requests, and HEAD
automatically. If anything goes wrong with the I/O, the error code
propagates to the error handler chain.
But there is a question we have carefully avoided. You can write a
handler. You know what it receives and what it returns. You know about
error handlers and exception handlers. What you don’t yet know is how
to install a handler — how to tell the router "when a GET request
arrives at /posts/:id, call this function."
That is the job of the router, and it is the subject of the next page.