Build a Minimal C++ HTTP Framework from Scratch with Asio
이 글의 핵심
Build a minimal HTTP server in C++ with Boost.Asio: parse request lines and headers, build a method+path router, chain middleware, and serve responses asynchronously. Understand the internals Beast and Crow abstract away.
Why Build Your Own HTTP Server?
Production HTTP in C++ means Boost.Beast, Crow, or Drogon. So why build one from scratch?
Because understanding the internals makes you better at using the production libraries. Once you have hand-written an HTTP parser, a routing table, and a middleware chain, reading Beast source code stops being mysterious — you recognize every part.
There are also real use cases for a minimal custom server:
- Embedded targets — Beast is large; a minimal parser + router can fit in tens of kilobytes
- Mixed protocols — HTTP on the same port as a custom TCP protocol; owning the parser pipeline makes dispatch easier
- Interview prep — “how does routing work?” is a common senior C++ interview question
This article builds a complete, working minimal HTTP server: async accept, HTTP/1.1 request parsing, method+path routing, and a middleware chain. All in under 400 lines of C++.
Architecture Overview
The data flow is:
Client → Acceptor → Session (async_read) → Parser → Router → Middleware chain → Handler → async_write → Client
Each component has a single responsibility:
| Component | Role |
|---|---|
Acceptor | Listens on a port, spawns a Session for each connection |
Session | Reads bytes until \r\n\r\n, then optional body by Content-Length |
Parser | Converts raw bytes into Request (method, path, headers, body) |
Router | Maps (method, path) to a handler function |
Middleware | Wraps handlers: logging, auth, CORS headers |
Handler | Business logic, returns a Response |
| Serializer | Converts Response to HTTP bytes for async_write |
Request and Response Types
#include <string>
#include <unordered_map>
#include <functional>
struct Request {
std::string method; // "GET", "POST", ...
std::string path; // "/api/items"
std::string version; // "HTTP/1.1"
std::unordered_map<std::string, std::string> headers;
std::string body;
};
struct Response {
int status = 200;
std::string status_text = "OK";
std::unordered_map<std::string, std::string> headers;
std::string body;
Response() {
headers["Content-Type"] = "text/plain";
}
// Factory helpers
static Response ok(std::string body) {
Response r;
r.body = std::move(body);
r.headers["Content-Length"] = std::to_string(r.body.size());
return r;
}
static Response json(std::string body) {
Response r;
r.body = std::move(body);
r.headers["Content-Type"] = "application/json";
r.headers["Content-Length"] = std::to_string(r.body.size());
return r;
}
static Response error(int code, std::string msg) {
Response r;
r.status = code;
r.status_text = std::move(msg);
r.body = r.status_text;
r.headers["Content-Length"] = std::to_string(r.body.size());
return r;
}
// Serialize to HTTP/1.1 wire format
std::string serialize() const {
std::string out;
out += "HTTP/1.1 " + std::to_string(status) + " " + status_text + "\r\n";
for (const auto& [k, v] : headers)
out += k + ": " + v + "\r\n";
out += "\r\n";
out += body;
return out;
}
};
HTTP Parser
Parsing HTTP/1.1 is line-by-line until the blank line separating headers from body:
#include <sstream>
#include <algorithm>
// Remove trailing \r from lines produced by getline on \r\n input
static std::string stripCR(std::string s) {
if (!s.empty() && s.back() == '\r') s.pop_back();
return s;
}
// Returns false if the request is malformed
bool parseRequest(const std::string& raw, Request& req) {
std::istringstream stream(raw);
std::string line;
// Request line: "GET /path HTTP/1.1"
if (!std::getline(stream, line)) return false;
line = stripCR(line);
std::istringstream req_line(line);
if (!(req_line >> req.method >> req.path >> req.version))
return false;
// Headers: "Key: Value" until blank line
while (std::getline(stream, line)) {
line = stripCR(line);
if (line.empty()) break; // end of headers
auto colon = line.find(':');
if (colon == std::string::npos) return false;
std::string key = line.substr(0, colon);
std::string val = line.substr(colon + 1);
// Trim leading whitespace from value
val.erase(0, val.find_first_not_of(' '));
// Normalize header key to lowercase
std::transform(key.begin(), key.end(), key.begin(), ::tolower);
req.headers[key] = val;
}
// Body — read Content-Length bytes
auto it = req.headers.find("content-length");
if (it != req.headers.end()) {
size_t len = std::stoul(it->second);
req.body.resize(len);
stream.read(req.body.data(), static_cast<std::streamsize>(len));
}
return true;
}
Router
A simple map-based router. The key is method + " " + path:
using Handler = std::function<Response(const Request&)>;
using Next = std::function<Response(const Request&)>;
using Middleware = std::function<Response(const Request&, Next)>;
class Router {
std::unordered_map<std::string, Handler> routes_;
std::vector<Middleware> middleware_;
std::string makeKey(const std::string& method, const std::string& path) {
return method + " " + path;
}
public:
void get(const std::string& path, Handler h) {
routes_[makeKey("GET", path)] = std::move(h);
}
void post(const std::string& path, Handler h) {
routes_[makeKey("POST", path)] = std::move(h);
}
void use(Middleware mw) {
middleware_.push_back(std::move(mw));
}
Response dispatch(const Request& req) const {
auto it = routes_.find(req.method + " " + req.path);
if (it == routes_.end())
return Response::error(404, "Not Found");
Handler handler = it->second;
// Build middleware chain (right-to-left)
// The innermost Next calls the actual handler
Next chain = [&handler](const Request& r) {
return handler(r);
};
for (int i = static_cast<int>(middleware_.size()) - 1; i >= 0; --i) {
Middleware mw = middleware_[i];
Next outer_chain = chain;
chain = [mw, outer_chain](const Request& r) {
return mw(r, outer_chain);
};
}
return chain(req);
}
};
Middleware Examples
// Logging middleware
Middleware logger = [](const Request& req, Next next) {
auto start = std::chrono::steady_clock::now();
auto resp = next(req);
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start).count();
std::printf("[%s] %s %s → %d (%ldms)\n",
req.headers.count("host") ? req.headers.at("host").c_str() : "-",
req.method.c_str(), req.path.c_str(), resp.status, ms);
return resp;
};
// Auth middleware (bearer token check)
Middleware auth = [](const Request& req, Next next) {
auto it = req.headers.find("authorization");
if (it == req.headers.end() || it->second != "Bearer secret-token")
return Response::error(401, "Unauthorized");
return next(req);
};
// CORS headers middleware
Middleware cors = [](const Request& req, Next next) {
auto resp = next(req);
resp.headers["Access-Control-Allow-Origin"] = "*";
resp.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS";
return resp;
};
Async Session (Boost.Asio)
The session reads the HTTP request asynchronously, dispatches to the router, and writes the response:
#include <boost/asio.hpp>
#include <memory>
namespace asio = boost::asio;
using tcp = asio::ip::tcp;
class Session : public std::enable_shared_from_this<Session> {
tcp::socket socket_;
asio::streambuf buffer_;
const Router& router_;
public:
Session(tcp::socket socket, const Router& router)
: socket_(std::move(socket)), router_(router) {}
void start() { readHeaders(); }
private:
void readHeaders() {
auto self = shared_from_this();
// Read until the blank line separating headers from body
asio::async_read_until(socket_, buffer_, "\r\n\r\n",
[self](boost::system::error_code ec, std::size_t bytes_transferred) {
if (ec) return; // connection closed or error
// Extract the header section
std::string raw{
asio::buffers_begin(self->buffer_.data()),
asio::buffers_begin(self->buffer_.data()) + bytes_transferred
};
self->buffer_.consume(bytes_transferred);
Request req;
if (!parseRequest(raw, req)) {
self->sendResponse(Response::error(400, "Bad Request"));
return;
}
// If there is a body, read it
auto it = req.headers.find("content-length");
if (it != req.headers.end()) {
size_t body_len = std::stoul(it->second);
if (body_len > 0) {
self->readBody(std::move(req), body_len);
return;
}
}
self->sendResponse(self->router_.dispatch(req));
});
}
void readBody(Request req, std::size_t len) {
auto self = shared_from_this();
asio::async_read(socket_, buffer_, asio::transfer_exactly(len),
[self, req = std::move(req)](boost::system::error_code ec, std::size_t n) mutable {
if (ec) return;
req.body = std::string{
asio::buffers_begin(self->buffer_.data()),
asio::buffers_begin(self->buffer_.data()) + n
};
self->buffer_.consume(n);
self->sendResponse(self->router_.dispatch(req));
});
}
void sendResponse(Response resp) {
auto self = shared_from_this();
auto data = std::make_shared<std::string>(resp.serialize());
asio::async_write(socket_, asio::buffer(*data),
[self, data](boost::system::error_code /*ec*/, std::size_t /*n*/) {
// Connection closed after response (HTTP/1.0 style)
// For keep-alive, call self->readHeaders() here instead
});
}
};
Acceptor
class HttpServer {
asio::io_context& io_;
tcp::acceptor acceptor_;
Router router_;
public:
HttpServer(asio::io_context& io, unsigned short port)
: io_(io)
, acceptor_(io, tcp::endpoint(tcp::v4(), port))
{}
Router& router() { return router_; }
void run() {
acceptOne();
io_.run();
}
private:
void acceptOne() {
acceptor_.async_accept(
[this](boost::system::error_code ec, tcp::socket socket) {
if (!ec) {
std::make_shared<Session>(std::move(socket), router_)->start();
}
acceptOne(); // accept next connection
});
}
};
Putting It Together
#include <iostream>
#include <csignal>
asio::io_context io;
void signalHandler(int) { io.stop(); }
int main() {
std::signal(SIGINT, signalHandler);
HttpServer server(io, 8080);
// Register middleware
server.router().use(logger);
server.router().use(cors);
// Register routes
server.router().get("/", [](const Request&) {
return Response::ok("Hello from C++ HTTP server!\n");
});
server.router().get("/health", [](const Request&) {
return Response::json(R"({"status":"ok"})");
});
server.router().post("/echo", [](const Request& req) {
return Response::json(req.body);
});
server.router().get("/protected", [](const Request& req) {
return Response::ok("Secret data\n");
});
// Apply auth only to /protected by wrapping the handler:
// Or apply auth middleware globally before other routes
std::cout << "Listening on port 8080\n";
server.run();
}
Test with curl:
curl http://localhost:8080/
# Hello from C++ HTTP server!
curl http://localhost:8080/health
# {"status":"ok"}
curl -X POST http://localhost:8080/echo -d '{"key":"value"}' -H "Content-Type: application/json"
# {"key":"value"}
Common Failures
| Issue | Cause | Fix |
|---|---|---|
| Connection reset before response | socket_ destroyed before async_write completes | Use shared_from_this() to keep session alive |
\r left on header values | getline on \r\n streams leaves \r | stripCR() after every getline |
| Body not read (Content-Length present) | Only reading up to \r\n\r\n | After headers, do a second async_read(transfer_exactly(len)) |
| EOF treated as error | Connection closed normally | if (ec == asio::error::eof) return; |
| Huge body accepted | No size cap → DoS risk | Check Content-Length before reading; return 413 if too large |
buffer_.consume not called | Next request reads stale data | Always consume processed bytes |
When to Use Boost.Beast Instead
| Situation | Choice |
|---|---|
| Production service, correctness matters | Beast — standards-complete HTTP/1.1 and HTTP/2, full chunked encoding, WebSocket |
| Quick REST API prototype | Crow or Drogon — header-only, Express-like API |
| Embedded target, <50KB budget | Custom minimal parser (this article) |
| Learning how HTTP parsing works | Custom minimal parser (this article) |
Library comparison:
├── Boost.Beast → production HTTP/WebSocket on Asio; comprehensive
├── Crow → header-only, Express API, fast to prototype
├── Drogon → full async stack, C++17, ORM included
└── Custom minimal → learning, embedded, custom protocols
Key Takeaways
- Parser: read until
\r\n\r\n, then read body by Content-Length — two async reads, not one stripCRafter everygetline—\r\nline endings leave a\rthat breaks header parsing- Router: a
std::unordered_map<string, Handler>keyed by"METHOD /path"covers 90% of routing needs - Middleware chain: build right-to-left by wrapping
Nextfunctions — each middleware calls the inner chain shared_from_thisin async lambdas — keeps the session alive untilasync_writecompletes- Body size cap: always check Content-Length before reading; reject oversized requests with 413
- Use Beast for production — this custom implementation teaches the concepts; Beast handles edge cases, chunked encoding, keep-alive, and HTTP/2
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. HTTP parsing, routing, middleware chains, and async I/O with Boost.Asio. When to use Beast/Crow vs a minimal custom serv… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ REST API 서버 완벽 가이드 | Beast 라우팅·JSON·미들웨어 [#31-2]
- C++ HTTP 기초 완벽 가이드 | 요청/응답 파싱·헤더·청크 인코딩·Beast 실전 [#30-1]
- C++ Redis 클론 | Modern C++ 인메모리 KV 스토어 [#48-1]
이 글에서 다루는 키워드 (관련 검색어)
C++, HTTP, web framework, Asio, routing, middleware, Boost 등으로 검색하시면 이 글이 도움이 됩니다.