본문으로 건너뛰기
Previous
Next
Build a Minimal C++ HTTP Framework from Scratch with Asio

Build a Minimal C++ HTTP Framework from Scratch with Asio

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:

ComponentRole
AcceptorListens on a port, spawns a Session for each connection
SessionReads bytes until \r\n\r\n, then optional body by Content-Length
ParserConverts raw bytes into Request (method, path, headers, body)
RouterMaps (method, path) to a handler function
MiddlewareWraps handlers: logging, auth, CORS headers
HandlerBusiness logic, returns a Response
SerializerConverts 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

IssueCauseFix
Connection reset before responsesocket_ destroyed before async_write completesUse shared_from_this() to keep session alive
\r left on header valuesgetline on \r\n streams leaves \rstripCR() after every getline
Body not read (Content-Length present)Only reading up to \r\n\r\nAfter headers, do a second async_read(transfer_exactly(len))
EOF treated as errorConnection closed normallyif (ec == asio::error::eof) return;
Huge body acceptedNo size cap → DoS riskCheck Content-Length before reading; return 413 if too large
buffer_.consume not calledNext request reads stale dataAlways consume processed bytes

When to Use Boost.Beast Instead

SituationChoice
Production service, correctness mattersBeast — standards-complete HTTP/1.1 and HTTP/2, full chunked encoding, WebSocket
Quick REST API prototypeCrow or Drogon — header-only, Express-like API
Embedded target, <50KB budgetCustom minimal parser (this article)
Learning how HTTP parsing worksCustom 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
  • stripCR after every getline\r\n line endings leave a \r that 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 Next functions — each middleware calls the inner chain
  • shared_from_this in async lambdas — keeps the session alive until async_write completes
  • 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++, HTTP, web framework, Asio, routing, middleware, Boost 등으로 검색하시면 이 글이 도움이 됩니다.