1 The Problem
We want a REST API: a web service that lets clients create, read, update, and delete records (a to-do list, say) using standard HTTP methods (GET, POST, PUT, DELETE) and JSON. It teaches the conventions of REST — how the entire web of apps and services talks to itself.
2 How to Think About It
Every incoming connection gets handled on its own thread, which means the shared todo list needs real protection against two requests arriving at once. That protection is the one part of this project most worth reading carefully.
std::thread, detached. → 3. Parse the HTTP request (method, path, and a Content-Length-aware body). → 4. Route it through handle_request, which is the only part touching the shared Store. → 5. Write the JSON response back and close the connection.
3 The Build — explained part by part
Here is the complete API. The route table (handle_request) is deliberately kept free of any socket code, so it can be tested by calling it directly — every test below does exactly that, with no network involved at all.
#pragma once
#include <mutex>
#include <optional>
#include <string>
#include <vector>
struct Todo {
int id;
std::string task;
bool done;
};
// All the mutable state behind one std::mutex, since this server handles
// each connection on its own thread (see main.cpp) -- without the lock,
// two requests arriving at once could corrupt the item list or hand out a
// duplicate id. Locking happens through std::lock_guard inside each
// method, not by hand: even if a line between lock and unlock were to
// throw, the mutex still unlocks itself when the guard goes out of scope,
// unlike C's pthread_mutex_lock/pthread_mutex_unlock pair, which needs
// every exit path to remember to unlock.
class Store {
public:
// Appends a new, not-done todo with the next never-reused id.
Todo create(const std::string &task);
// Returns all todos, already in ascending-id order.
std::vector<Todo> list() const;
// Removes the todo with the given id. Returns true if found.
bool remove(int id);
private:
mutable std::mutex mutex_;
std::vector<Todo> items_;
int next_id_ = 1;
};
// Minimal JSON encoding, escaping only quotes and backslashes -- enough
// for this project's plain-text task names. A real project reaches for a
// JSON library; like C, this build environment cannot reach a package
// registry to fetch one (the same constraint this project's C version hit
// with no equivalent of serde_json or Jackson available either).
std::string todo_to_json(const Todo &t);
std::string todos_to_json(const std::vector<Todo> &items);
// Just enough of a JSON object parser for this API's one request shape:
// {"task": "some text"}. Returns std::nullopt if no "task" field was found.
std::optional<std::string> parse_task_field(const std::string &body);
struct Response {
std::string status;
std::string body;
};
// The whole route table for this tiny API, kept separate from socket
// handling so it can be tested by calling it directly with a fake request
// -- no network involved.
Response handle_request(Store &store, const std::string &method,
const std::string &path, const std::string &body);
#include "RestApi.hpp"
#include <algorithm>
#include <sstream>
Todo Store::create(const std::string &task) {
std::lock_guard<std::mutex> lock(mutex_);
Todo t{next_id_++, task, false};
items_.push_back(t);
return t;
}
std::vector<Todo> Store::list() const {
std::lock_guard<std::mutex> lock(mutex_);
return items_; // a copy, so the caller can use it after the lock is released
}
bool Store::remove(int id) {
std::lock_guard<std::mutex> lock(mutex_);
auto it = std::find_if(items_.begin(), items_.end(),
[id](const Todo &t) { return t.id == id; });
if (it == items_.end()) return false;
items_.erase(it);
return true;
}
static std::string json_escape(const std::string &s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
if (c == '"' || c == '\\') out += '\\';
out += c;
}
return out;
}
std::string todo_to_json(const Todo &t) {
std::ostringstream out;
out << "{\"id\":" << t.id << ",\"task\":\"" << json_escape(t.task)
<< "\",\"done\":" << (t.done ? "true" : "false") << "}";
return out.str();
}
std::string todos_to_json(const std::vector<Todo> &items) {
std::ostringstream out;
out << "[";
for (size_t i = 0; i < items.size(); i++) {
if (i > 0) out << ",";
out << todo_to_json(items[i]);
}
out << "]";
return out.str();
}
std::optional<std::string> parse_task_field(const std::string &body) {
auto key = body.find("\"task\"");
if (key == std::string::npos) return std::nullopt;
auto colon = body.find(':', key);
if (colon == std::string::npos) return std::nullopt;
auto open_quote = body.find('"', colon);
if (open_quote == std::string::npos) return std::nullopt;
auto close_quote = body.find('"', open_quote + 1);
if (close_quote == std::string::npos) return std::nullopt;
return body.substr(open_quote + 1, close_quote - open_quote - 1);
}
Response handle_request(Store &store, const std::string &method,
const std::string &path, const std::string &body) {
if (method == "GET" && path == "/todos") {
return Response{"200 OK", todos_to_json(store.list())};
}
if (method == "POST" && path == "/todos") {
auto task = parse_task_field(body);
if (!task) return Response{"400 Bad Request", "{\"error\":\"missing task field\"}"};
Todo created = store.create(*task);
return Response{"201 Created", todo_to_json(created)};
}
if (method == "DELETE" && path.rfind("/todos/", 0) == 0) {
std::string id_str = path.substr(std::string("/todos/").size());
try {
int id = std::stoi(id_str);
if (store.remove(id)) return Response{"204 No Content", ""};
return Response{"404 Not Found", "{\"error\":\"no such todo\"}"};
} catch (const std::exception &) {
return Response{"400 Bad Request", "{\"error\":\"invalid id\"}"};
}
}
return Response{"404 Not Found", "{\"error\":\"no such route\"}"};
}
#define _POSIX_C_SOURCE 200809L
#include "RestApi.hpp"
#include <arpa/inet.h>
#include <cstring>
#include <iostream>
#include <netinet/in.h>
#include <sstream>
#include <sys/socket.h>
#include <thread>
#include <unistd.h>
// Reads a full HTTP/1.1 request off `fd`: headers first, then the body if
// Content-Length says there is one. Loops recv() until the blank-line
// boundary and, if needed, the full body have arrived -- a single recv()
// call is not guaranteed to return the whole request at once.
static std::string read_request(int fd) {
std::string data;
char buf[4096];
size_t header_end = std::string::npos;
ssize_t n;
while ((header_end = data.find("\r\n\r\n")) == std::string::npos) {
n = recv(fd, buf, sizeof(buf), 0);
if (n <= 0) return data;
data.append(buf, static_cast<size_t>(n));
}
size_t content_length = 0;
auto cl_pos = data.find("Content-Length:");
if (cl_pos != std::string::npos && cl_pos < header_end) {
content_length = static_cast<size_t>(std::stoi(data.substr(cl_pos + 15)));
}
size_t body_have = data.size() - (header_end + 4);
while (body_have < content_length) {
n = recv(fd, buf, sizeof(buf), 0);
if (n <= 0) break;
data.append(buf, static_cast<size_t>(n));
body_have = data.size() - (header_end + 4);
}
return data;
}
static void serve_connection(int client_fd, Store *store) {
std::string request = read_request(client_fd);
std::istringstream lines(request);
std::string request_line;
std::getline(lines, request_line);
std::istringstream rl(request_line);
std::string method, path, version;
rl >> method >> path >> version;
auto header_end = request.find("\r\n\r\n");
std::string body = header_end == std::string::npos ? "" : request.substr(header_end + 4);
Response resp = handle_request(*store, method, path, body);
std::ostringstream out;
out << "HTTP/1.1 " << resp.status << "\r\n"
<< "Content-Type: application/json\r\n"
<< "Content-Length: " << resp.body.size() << "\r\n"
<< "Connection: close\r\n\r\n"
<< resp.body;
std::string response = out.str();
send(client_fd, response.c_str(), response.size(), 0);
close(client_fd);
}
int main(int argc, char **argv) {
int port = argc > 1 ? std::stoi(argv[1]) : 8080;
Store store;
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(static_cast<uint16_t>(port));
if (bind(server_fd, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)) < 0) {
std::cerr << "bind failed\n";
return 1;
}
listen(server_fd, 16);
std::cout << "Listening on port " << port << "\n";
while (true) {
int client_fd = accept(server_fd, nullptr, nullptr);
if (client_fd < 0) continue;
// A lambda captures what the thread needs by value, instead of C's
// pthread_create, which needs a raw function pointer plus a void*
// argument the callback has to cast back to the right type by hand.
std::thread(serve_connection, client_fd, &store).detach();
}
}
Store method — RAII locking. The mutex unlocks itself automatically when lock goes out of scope, on every return path, even one caused by an exception. C’s version of this project locks and unlocks a pthread_mutex_t by hand at the start and end of every function, which means every exit path (including an early return the original author might add later) has to remember to unlock — miss one, and every other thread deadlocks waiting for a lock that will never be released.std::thread(serve_connection, client_fd, &store).detach(); — a type-safe replacement for
pthread_create, which needs a raw function pointer and a void* argument the callback has to cast back to the right type by hand. Here the compiler checks the argument types at the call site itself.Response handle_request(Store &store, ...) — the entire route table as one pure-ish function taking plain strings in and returning a plain
Response struct out, with no socket code anywhere inside it. This is what makes 9 of this project’s 9 tests possible without a single real network call.read_request in
main.cpp — loops recv() until the \r\n\r\n header boundary arrives, then keeps reading until Content-Length bytes of body have arrived too, because a single recv() call is never guaranteed to return an entire HTTP request at once.
Store’s internal vector from list() — another thread could resize or mutate that vector while the caller is still reading from it, a data race even with the mutex, because the lock is released the moment list() returns.list() does, so the caller has its own safe snapshot once the lock is released.recv() call returns the whole HTTP request — TCP is a byte stream with no message boundaries, so a large request (or a slow connection) can easily arrive in several pieces.Content-Length, the full body, as read_request does here.4 Test & Prove Each Part
Nine checks, from the Store’s id-management and thread-safety-relevant copy semantics, through JSON encoding and a hand-written request-body parser, to the full route table’s behaviour for GET, POST, and DELETE — all without a single real socket.
#include "RestApi.hpp"
#include <cassert>
#include <iostream>
#define RUN(name) do { name(); std::cout << "PASS: " << #name << "\n"; } while (0)
static void store_create_assigns_never_reused_ids() {
Store store;
Todo a = store.create("first");
Todo b = store.create("second");
assert(a.id == 1);
assert(b.id == 2);
assert(!a.done);
}
static void store_list_returns_todos_in_ascending_id_order() {
Store store;
store.create("first");
store.create("second");
auto items = store.list();
assert(items.size() == 2);
assert(items[0].id == 1 && items[1].id == 2);
}
static void store_remove_finds_by_id_and_reports_success() {
Store store;
Todo a = store.create("first");
store.create("second");
assert(store.remove(a.id));
assert(store.list().size() == 1);
assert(!store.remove(999));
}
static void todo_to_json_escapes_quotes_and_backslashes() {
Todo t{1, "say \"hi\" \\ bye", false};
std::string json = todo_to_json(t);
assert(json.find("\\\"hi\\\"") != std::string::npos);
assert(json.find("\\\\") != std::string::npos);
}
static void parse_task_field_extracts_the_task_string() {
auto task = parse_task_field(R"({"task": "buy milk"})");
assert(task.has_value());
assert(*task == "buy milk");
}
static void parse_task_field_returns_nullopt_when_missing() {
assert(!parse_task_field(R"({"other": "value"})").has_value());
}
static void handle_request_get_todos_lists_everything() {
Store store;
store.create("first");
Response r = handle_request(store, "GET", "/todos", "");
assert(r.status == "200 OK");
assert(r.body.find("first") != std::string::npos);
}
static void handle_request_post_creates_a_todo() {
Store store;
Response r = handle_request(store, "POST", "/todos", R"({"task": "new task"})");
assert(r.status == "201 Created");
assert(r.body.find("new task") != std::string::npos);
assert(store.list().size() == 1);
}
static void handle_request_delete_removes_then_404s_on_repeat() {
Store store;
Todo created = store.create("to remove");
Response first = handle_request(store, "DELETE", "/todos/" + std::to_string(created.id), "");
assert(first.status == "204 No Content");
Response second = handle_request(store, "DELETE", "/todos/" + std::to_string(created.id), "");
assert(second.status == "404 Not Found");
}
int main() {
RUN(store_create_assigns_never_reused_ids);
RUN(store_list_returns_todos_in_ascending_id_order);
RUN(store_remove_finds_by_id_and_reports_success);
RUN(todo_to_json_escapes_quotes_and_backslashes);
RUN(parse_task_field_extracts_the_task_string);
RUN(parse_task_field_returns_nullopt_when_missing);
RUN(handle_request_get_todos_lists_everything);
RUN(handle_request_post_creates_a_todo);
RUN(handle_request_delete_removes_then_404s_on_repeat);
std::cout << "All tests passed.\n";
return 0;
}
Compile and run with g++ -std=c++20 -Wall -Wextra -Wpedantic -pthread -o test_run RestApi.cpp test_RestApi.cpp && ./test_run.
5 The Interface
Even a tiny hand-rolled API has a real interface. Here it is, documented the same way a professional would describe any HTTP service.
What it expects
{"task": "buy milk"}What it returns
{"id":1,"task":"buy milk","done":false}6 Run It & Automate It
Save the code as RestApi.hpp / RestApi.cpp / main.cpp and compile it with g++ — that turns your source directly into a native executable for your machine. No separate runtime needed: the compiled binary runs on its own.
g++ -std=c++20 -pthread -o restapi main.cpp RestApi.cpp && ./restapi 8080Then, from another terminal, talk to it with curl.
A CI tool like Jenkins runs the same compile-then-test-then-check-for-leaks steps automatically whenever the code changes — every line below has a plain explanation.
$ curl -X POST http://127.0.0.1:8080/todos -d '{"task": "buy milk"}'
{"id":1,"task":"buy milk","done":false}
$ curl http://127.0.0.1:8080/todos
[{"id":1,"task":"buy milk","done":false}]
$ curl -X DELETE http://127.0.0.1:8080/todos/1 -w '\nHTTP:%{http_code}\n'
HTTP:204
$ curl -X DELETE http://127.0.0.1:8080/todos/1 -w '\nHTTP:%{http_code}\n'
{"error":"no such todo"}
HTTP:404read_request is not blocking forever waiting for a Content-Length that was never actually sent."task" with a colon and a quoted string value must appear in the body — parse_task_field does a literal text search, not real JSON parsing, so anything else (a different key name, unquoted value) will not match.// Jenkinsfile — compiles, tests, and checks for leaks on every change.
pipeline {
agent any
stages {
stage('Get the code') {
// download the latest code
steps { checkout scm }
}
stage('Compile') {
steps {
// confirm a compiler is installed
sh 'g++ --version'
// compile with strict warnings on
sh 'g++ -std=c++20 -Wall -Wextra -o app *.cpp -pthread'
}
}
stage('Run the tests') {
steps {
// prints PASS/FAIL, exits non-zero on failure
sh './app'
}
}
stage('Check for memory leaks') {
steps {
// fails the build on any leak or invalid access
sh 'valgrind --error-exitcode=1 --leak-check=full ./app'
}
}
}
post {
success { echo 'All tests passed, no leaks found.' }
failure { echo 'A test or Valgrind check failed — see above.' }
}
}
- Add a PATCH /todos/<id> route to mark a todo done. (Teaches: extending the route table and the
Storetogether.) - Add a thread pool instead of a thread per connection. (Teaches: why unbounded thread creation is a real resource risk under load, and how a fixed pool of worker threads pulling from a queue avoids it.)
- Return proper HTTP error codes for malformed JSON, not just a missing field. (Teaches: how much a real JSON parser actually validates that a hand-written substring search does not.)
std::lock_guard is exception-safe in a way manual pthread_mutex lock/unlock calls are not, that std::thread with a lambda is a type-safe replacement for pthread_create’s void* callback, and why Store::list must return a copy rather than a reference into thread-shared state. Related: Concurrency in C++, Classes and RAII.