본문으로 건너뛰기 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

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

Frequently Asked Questions (FAQ)

Q. When would I use this in practice?

A. HTTP parsing, routing, middleware chains, and async I/O with Boost.Asio.

Q. What should I read before this?

A. Follow the previous article or related articles links at the bottom of each post to learn in sequence. See the C++ series index for the full picture.

Q. Where can I study this more deeply?

A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.


Other articles related to this topic.


Keywords Covered in This Article (Related Search Terms)

This article covers C++, HTTP, web framework, Asio, routing, middleware, Boost.