A small C++17 HTTP service sample.
- HTTP server:
cpp-httplib(header-only, see external/httplib.h) - JSON:
nlohmann::json - Logging:
spdlog(console + rotating file) - CLI parsing:
cxxopts
The service now exposes basic observability endpoints by default:
GET /healthzGET /status
- CMake >= 3.20
- A C++17 compiler (GCC / Clang / MSVC)
You also need these libraries discoverable by CMake via find_package(... CONFIG REQUIRED):
- nlohmann-json
- spdlog
- fmt
- cxxopts
Install the dependencies using your system package manager (or build/install them yourself) so that CMake can find their Config packages.
Example (Ubuntu/Debian):
sudo apt update
sudo apt install -y \
nlohmann-json3-dev \
libspdlog-dev \
libfmt-dev \
libcxxopts-devIf your distro does not provide cxxopts as a CMake package, build and install it from source, then set CMAKE_PREFIX_PATH to the install prefix when configuring.
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -jNote: the CMake build adds a POST_BUILD step that copies config.json next to the built executable.
This project uses find_package(... CONFIG REQUIRED). On Windows, the simplest way to satisfy those dependencies is to use vcpkg in toolchain mode.
- Clone and bootstrap vcpkg (see vcpkg docs).
- Set an environment variable
VCPKG_ROOTpointing to your vcpkg folder.
From a Developer PowerShell:
cd $env:VCPKG_ROOT
./vcpkg install nlohmann-json spdlog fmt cxxoptsIf you want a specific architecture, add a triplet, for example:
./vcpkg install nlohmann-json spdlog fmt cxxopts --triplet x64-windowsFrom the repository root:
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_ROOT\scripts\buildsystems\vcpkg.cmake"
cmake --build build --config ReleaseNote: the build copies config.json next to the built executable.
Linux/macOS:
cd build
./app_servicesWindows (example for CMake multi-config generators):
.\build\Release\app_services.exeYou should see logs like:
Using data_dir: ...Listening on 0.0.0.0:8080 (threads=...)- Per-request logs (non-observability paths):
GET /unknown 404 remote_addr=...
The service loads config.json from:
- Windows: the executable directory
- Linux/macOS: the current working directory
If you start the binary from a different directory (not the one containing config.json), config loading will fail.
Supported fields:
host(string): bind address, default0.0.0.0port(int): listen port, default8080threads(number): request worker threads0means “auto” (usesstd::thread::hardware_concurrency())
data_dir(string, optional): data directory- if empty, defaults to:
- Linux/macOS:
./data - Windows:
%ProgramData%/ServicesSample
- Linux/macOS:
- if empty, defaults to:
Example config.json:
{
"host": "0.0.0.0",
"port": 8080,
"threads": 6
}On startup, the app creates:
data_dir/data_dir/logs/
And writes a rotating log file:
data_dir/logs/app.log(10MB x 5 files)
If you don’t set data_dir on Linux/macOS, logs will be under:
./data/logs/app.log
Routing is wrapped by ApiRouter (see src/api_router.h). Register endpoints inside register_handlers(ApiRouter& api) (see src/handlers.cpp).
A handler has the signature:
using Handler = std::function<void(const httplib::Request&, httplib::Response&)>;Example endpoint GET /hello (already present):
api.get("/hello", [](const httplib::Request& req, httplib::Response& res) {
nlohmann::json j;
j["message"] = "Hello, World!";
res.status = 200;
res.set_content(j.dump(), "application/json");
});Available route registration helpers:
api.get(path, handler)api.post(path, handler)api.put(path, handler)api.del(path, handler)
Quick notes for handlers:
- Set
res.statusbefore returning. - Use
res.set_content(body, content_type)to set the response body. - If handlers can throw, the router supports centralized error handling via
set_exception_handler(...)(seeApiRouter).
To group endpoints under a prefix (e.g. /api/v1), use group(prefix, define_routes):
api.group("/api/v1", [](ApiRouter& r) {
r.get("/ping", [](const httplib::Request&, httplib::Response& res) {
res.status = 200;
res.set_content("pong", "text/plain; charset=utf-8");
});
});
// -> GET /api/v1/pingMiddleware has the signature:
using Next = std::function<void()>;
using Middleware = std::function<void(const httplib::Request&, httplib::Response&, Next)>;There are two kinds:
api.use(mw)runs before the route handlerapi.use_after(mw)runs after the route handler
Middleware runs as a chain. If you want the request to continue to the route handler (or the next middleware), you must call next(). If you set a response and don’t call next(), the request stops there (commonly used for auth/deny).
api.use([](const httplib::Request& req, httplib::Response& res, ApiRouter::Next next) {
const auto start = std::chrono::steady_clock::now();
next();
const auto end = std::chrono::steady_clock::now();
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
spdlog::info("{} {} took {}ms status={}", req.method, req.path, ms, res.status);
});api.use([](const httplib::Request& req, httplib::Response& res, ApiRouter::Next next) {
auto it = req.headers.find("x-api-key");
if (it == req.headers.end() || it->second != "secret") {
res.status = 401;
res.set_content("unauthorized", "text/plain; charset=utf-8");
return; // no next() => stop
}
next();
});api.use_after([](const httplib::Request&, httplib::Response& res, ApiRouter::Next next) {
next();
res.set_header("x-service", "app_services");
});For a real example used in this repo, see the observability middleware in src/observability.cpp.
Returns plain text:
ok
Quick test:
curl -s http://127.0.0.1:8080/healthzReturns a small JSON payload (counters are process-wide):
{"requests_total":123,"requests_in_flight":0}Quick test:
curl -s http://127.0.0.1:8080/statusPrometheus text format endpoint. Currently disabled in handler registration.
Returns tracing/correlation headers (and echoes them in the body). Currently disabled in handler registration.
Unknown routes return:
{"error":"not found"}Supported options:
--servicerun as Windows Service (used by the installer-created service)
Note: Service recovery (automatic restarts on failure) is configured by the installer script. Control Manager defaults baked into the app.
Unable to open config file: ensureconfig.jsonis in the directory the app resolves it from (see “Configuration”).- Port already in use: change
portinconfig.json.