← thecodex.expert · The Codex Family of Knowledge
Tier 3 · Upper-Intermediate · C Project

REST API

A real, hand-rolled HTTP server over a raw socket, with thread-per-connection concurrency and a mutex-guarded store. Built entirely on POSIX sockets and pthreads, no framework involved.

🧠 Teaches how to think spoonfed, every age Last verified:

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.

Where this shows up: every mobile app backend, every single-page web app, every microservice, every public API (Stripe, Twitter, GitHub). REST is the lingua franca of the internet. Understanding it is essential to backend work.

2 How to Think About It

A REST API is a routing table plus shared state. Build the state and the routing logic first, entirely independent of sockets, then wire sockets on last.

The plan — in plain English
1. Define the shared Store (create/list/delete) guarded by a pthread_mutex_t. → 2. Write handle_request as pure routing logic: method + path + body in, a status + JSON body out. → 3. Parse a raw HTTP request off the socket by hand, including Content-Length. → 4. Spawn one pthread per connection so the server handles requests concurrently.

GET

POST

PUT

DELETE

Request arrives

Which method?

Return records as JSON

Create a record

Update a record

Remove a record

Send JSON response

3 The Build — explained part by part

Here is the complete API. C has no web framework in its standard library at all — nothing like Java’s built-in HttpServer, let alone a real framework — so this project does by hand exactly what a framework automates: request parsing, routing, JSON encoding, and thread safety.

CRestApi.h / RestApi.c / main.c
#ifndef REST_API_H
#define REST_API_H
#include <pthread.h>

#define MAX_TODOS 256
#define MAX_TASK 128
#define MAX_BODY 1024

typedef struct {
    int id;
    char task[MAX_TASK];
    int done;
} Todo;

/* All the mutable state behind one mutex, since this server handles each
 * connection on its own OS thread (see serve_all below) -- without the
 * lock, two requests arriving at once could corrupt `items` or hand out a
 * duplicate id. */
typedef struct {
    Todo items[MAX_TODOS];
    int count;
    int next_id;
    pthread_mutex_t lock;
} Store;

void store_init(Store *store);

/* Appends a new, not-done todo with the next never-reused id, holding the
 * lock for the whole operation. Returns the created todo. */
Todo store_create(Store *store, const char *task);

/* Copies up to `cap` todos (already in ascending-id order) into `out`.
 * Returns how many were copied. */
int store_list(const Store *store, Todo *out, int cap);

/* Removes the todo with the given id. Returns 1 if found, 0 otherwise. */
int store_delete(Store *store, int id);

typedef struct {
    char status[32];
    char body[MAX_BODY];
} Response;

/* 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; C's standard library has none, and this build environment
 * cannot reach a package registry to fetch one (the same constraint Rust's
 * and Java's versions of this project hit with serde_json/Jackson). */
void todo_to_json(const Todo *t, char *out, int out_cap);
void todos_to_json(const Todo *items, int count, char *out, int out_cap);

/* Just enough of a JSON object parser for this API's one request shape:
 * {"task": "some text"}. Returns 1 if a "task" field was found. */
int parse_task_field(const char *body, char *out, int out_cap);

/* 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 char *method, const char *path, const char *body);

#endif

#include "RestApi.h"
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>

void store_init(Store *store) {
    store->count = 0;
    store->next_id = 1;
    pthread_mutex_init(&store->lock, NULL);
}

Todo store_create(Store *store, const char *task) {
    pthread_mutex_lock(&store->lock);
    Todo t;
    t.id = store->next_id++;
    strncpy(t.task, task, sizeof(t.task) - 1);
    t.task[sizeof(t.task) - 1] = '\0';
    t.done = 0;
    store->items[store->count++] = t;
    pthread_mutex_unlock(&store->lock);
    return t;
}

int store_list(const Store *store, Todo *out, int cap) {
    int n = store->count < cap ? store->count : cap;
    memcpy(out, store->items, sizeof(Todo) * (size_t)n);
    return n;
}

int store_delete(Store *store, int id) {
    pthread_mutex_lock(&store->lock);
    for (int i = 0; i < store->count; i++) {
        if (store->items[i].id == id) {
            for (int j = i; j < store->count - 1; j++) store->items[j] = store->items[j + 1];
            store->count--;
            pthread_mutex_unlock(&store->lock);
            return 1;
        }
    }
    pthread_mutex_unlock(&store->lock);
    return 0;
}

static void escape_json(const char *s, char *out, int out_cap) {
    int o = 0;
    for (int i = 0; s[i] != '\0' && o < out_cap - 2; i++) {
        if (s[i] == '\\' || s[i] == '"') out[o++] = '\\';
        out[o++] = s[i];
    }
    out[o] = '\0';
}

void todo_to_json(const Todo *t, char *out, int out_cap) {
    char escaped[MAX_TASK * 2];
    escape_json(t->task, escaped, sizeof(escaped));
    snprintf(out, (size_t)out_cap, "{\"id\":%d,\"task\":\"%s\",\"done\":%s}",
             t->id, escaped, t->done ? "true" : "false");
}

void todos_to_json(const Todo *items, int count, char *out, int out_cap) {
    out[0] = '['; out[1] = '\0';
    char one[MAX_TASK * 2 + 64];
    for (int i = 0; i < count; i++) {
        todo_to_json(&items[i], one, sizeof(one));
        if (i > 0) strncat(out, ",", (size_t)(out_cap - (int)strlen(out) - 1));
        strncat(out, one, (size_t)(out_cap - (int)strlen(out) - 1));
    }
    strncat(out, "]", (size_t)(out_cap - (int)strlen(out) - 1));
}

int parse_task_field(const char *body, char *out, int out_cap) {
    const char *key = strstr(body, "\"task\"");
    if (!key) return 0;
    const char *after_key = key + strlen("\"task\"");
    const char *colon = strchr(after_key, ':');
    if (!colon) return 0;
    const char *p = colon + 1;
    while (*p == ' ' || *p == '\t') p++;
    if (*p != '"') return 0;
    p++;

    int o = 0;
    while (*p != '\0' && o < out_cap - 1) {
        if (*p == '"') { out[o] = '\0'; return 1; }
        if (*p == '\\' && *(p + 1) != '\0') { p++; out[o++] = *p; p++; continue; }
        out[o++] = *p;
        p++;
    }
    return 0; /* ran off the end without a closing quote */
}

static int is_valid_id(const char *s) {
    if (*s == '\0') return 0;
    for (const char *p = s; *p; p++) if (!isdigit((unsigned char)*p)) return 0;
    return 1;
}

Response handle_request(Store *store, const char *method, const char *path, const char *body) {
    Response r;
    if (strcmp(method, "GET") == 0 && strcmp(path, "/todos") == 0) {
        Todo items[MAX_TODOS];
        int n = store_list(store, items, MAX_TODOS);
        strcpy(r.status, "200 OK");
        todos_to_json(items, n, r.body, sizeof(r.body));
        return r;
    }
    if (strcmp(method, "POST") == 0 && strcmp(path, "/todos") == 0) {
        char task[MAX_TASK];
        if (parse_task_field(body, task, sizeof(task))) {
            Todo t = store_create(store, task);
            strcpy(r.status, "201 Created");
            todo_to_json(&t, r.body, sizeof(r.body));
        } else {
            strcpy(r.status, "400 Bad Request");
            strcpy(r.body, "{\"error\":\"missing \\\"task\\\" field\"}");
        }
        return r;
    }
    if (strcmp(method, "DELETE") == 0 && strncmp(path, "/todos/", 7) == 0) {
        const char *id_str = path + 7;
        if (!is_valid_id(id_str)) {
            strcpy(r.status, "400 Bad Request");
            strcpy(r.body, "{\"error\":\"invalid id\"}");
            return r;
        }
        int id = atoi(id_str);
        if (store_delete(store, id)) {
            strcpy(r.status, "204 No Content");
            r.body[0] = '\0';
        } else {
            strcpy(r.status, "404 Not Found");
            strcpy(r.body, "{\"error\":\"no such todo\"}");
        }
        return r;
    }
    strcpy(r.status, "404 Not Found");
    strcpy(r.body, "{\"error\":\"no such route\"}");
    return r;
}

#define _POSIX_C_SOURCE 200809L
#include "RestApi.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define PORT 8080
#define MAX_REQUEST 8192

/* Parses the request line and, if present, a Content-Length body, from a
 * raw HTTP/1.1 request read straight off the socket. A real project would
 * use a framework (unreachable here, see RestApi.h); this is what that
 * framework is doing underneath. */
static int read_request(int fd, char *method, int method_cap, char *path, int path_cap,
                         char *body, int body_cap) {
    static char buf[MAX_REQUEST];
    int total = 0, header_end = -1;

    while (total < MAX_REQUEST - 1) {
        ssize_t n = recv(fd, buf + total, (size_t)(MAX_REQUEST - 1 - total), 0);
        if (n <= 0) return 0;
        total += (int)n;
        buf[total] = '\0';
        char *sep = strstr(buf, "\r\n\r\n");
        if (sep) { header_end = (int)(sep - buf) + 4; break; }
    }
    if (header_end < 0) return 0;

    int content_length = 0;
    char *cl = strstr(buf, "Content-Length:");
    if (cl && cl < buf + header_end) content_length = atoi(cl + strlen("Content-Length:"));

    int body_have = total - header_end;
    while (body_have < content_length && total < MAX_REQUEST - 1) {
        ssize_t n = recv(fd, buf + total, (size_t)(MAX_REQUEST - 1 - total), 0);
        if (n <= 0) break;
        total += (int)n;
        buf[total] = '\0';
        body_have = total - header_end;
    }

    char req_line[512];
    char *line_end = strstr(buf, "\r\n");
    size_t line_len = line_end ? (size_t)(line_end - buf) : strlen(buf);
    if (line_len >= sizeof(req_line)) line_len = sizeof(req_line) - 1;
    memcpy(req_line, buf, line_len);
    req_line[line_len] = '\0';

    char *sp1 = strchr(req_line, ' ');
    if (!sp1) return 0;
    *sp1 = '\0';
    strncpy(method, req_line, (size_t)method_cap - 1);
    method[method_cap - 1] = '\0';

    char *sp2 = strchr(sp1 + 1, ' ');
    size_t path_len = sp2 ? (size_t)(sp2 - (sp1 + 1)) : strlen(sp1 + 1);
    if (path_len >= (size_t)path_cap) path_len = (size_t)path_cap - 1;
    memcpy(path, sp1 + 1, path_len);
    path[path_len] = '\0';

    int copy_len = body_have < body_cap - 1 ? body_have : body_cap - 1;
    if (copy_len > 0) memcpy(body, buf + header_end, (size_t)copy_len);
    body[copy_len > 0 ? copy_len : 0] = '\0';
    return 1;
}

typedef struct {
    Store *store;
    int fd;
} ConnArgs;

static void *serve_connection(void *arg) {
    ConnArgs *args = arg;
    char method[16], path[256], body[MAX_BODY];
    if (read_request(args->fd, method, sizeof(method), path, sizeof(path), body, sizeof(body))) {
        Response resp = handle_request(args->store, method, path, body);
        char out[MAX_BODY + 256];
        snprintf(out, sizeof(out),
                 "HTTP/1.1 %s\r\n"
                 "Content-Type: application/json\r\n"
                 "Content-Length: %zu\r\n"
                 "Connection: close\r\n\r\n%s",
                 resp.status, strlen(resp.body), resp.body);
        send(args->fd, out, strlen(out), 0);
    }
    close(args->fd);
    free(args);
    return NULL;
}

int main(void) {
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) { perror("socket"); return 1; }
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);
    if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("could not bind to :8080");
        return 1;
    }
    if (listen(listen_fd, 16) < 0) { perror("listen"); return 1; }

    static Store store;
    store_init(&store);
    printf("Listening on http://127.0.0.1:%d\n", PORT);

    for (;;) {
        int client_fd = accept(listen_fd, NULL, NULL);
        if (client_fd < 0) { perror("accept"); continue; }
        ConnArgs *args = malloc(sizeof(ConnArgs));
        args->store = &store;
        args->fd = client_fd;
        pthread_t thread;
        pthread_create(&thread, NULL, serve_connection, args);
        pthread_detach(thread);
    }
}
⚠ No in-browser playground here
C compiles to a real, native binary, so unlike the Python version of this project there is no editor above you can run in the browser. Copy the code below and run it on your own machine — it takes seconds once GCC or Clang is installed.
What each part does — in plain words
typedef struct { Todo items[MAX_TODOS]; int count; int next_id; pthread_mutex_t lock; } Store; — every connection runs on its own OS thread (see below), so without the mutex, two requests arriving at once could corrupt the array or hand out a duplicate id. Unlike Rust’s compiler, C cannot catch a missing lock for you — forgetting pthread_mutex_lock anywhere that touches items compiles cleanly and just corrupts data under real concurrent load.

Response handle_request(Store *store, const char *method, const char *path, const char *body) — the entire route table, written as a plain function that takes a method, path and body and returns a status and JSON body. It knows nothing about sockets, which is exactly why the tests below can call it directly with a fake request and no real network involved.

todo_to_json / parse_task_field — hand-rolled JSON encoding and a narrow, one-shape decoder, scanning character by character for the closing quote while unescaping \" and \\. C has no JSON library in its standard library and this sandbox cannot reach a package registry to fetch one.

pthread_create(&thread, NULL, serve_connection, args); pthread_detach(thread); — C has no async runtime built in (that is what libraries like libuv provide), so this server gets its concurrency the plain way: a real OS thread per connection. pthread_detach tells the runtime to reclaim the thread’s resources automatically when it finishes, since nothing ever calls pthread_join on it.
Common mistakes — and how to avoid them
✗ Touching store->items from serve_connection without going through a function that locks the mutex first — unlike Rust, this compiles fine and only breaks under real concurrent load, which makes it easy to miss in casual testing.
✓ Route every read or write of shared state through store_create/store_list/store_delete, which always lock internally, as this project does.
✗ Reading from the socket until it closes instead of reading exactly Content-Length bytes for the body — this client sends Connection: close, but a browser or another client might keep the connection open, and then the read would hang forever.
✓ Parse Content-Length from the headers and recv exactly that many more bytes, as read_request does.

4 Test & Prove Each Part

We test the routing logic directly — no real socket involved — by calling handle_request with fake methods, paths and bodies.

Posting a task creates it and it shows up in the list
Deleting a todo succeeds once, then 404s on the same id
Posting with no task field is rejected with 400
An unknown route returns 404
The narrow JSON parser correctly unescapes an escaped quote
Deleting an invalid (non-numeric) id is a 400, not a crash
IDs are never reused after a delete
The list stays ordered by id
Ctest_RestApi.c
#include "RestApi.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>

#define RUN(name) do { name(); printf("PASS: %s\n", #name); } while (0)

static void creates_and_lists_todos(void) {
    Store store;
    store_init(&store);

    Response created = handle_request(&store, "POST", "/todos", "{\"task\":\"Buy milk\"}");
    assert(strcmp(created.status, "201 Created") == 0);
    assert(strstr(created.body, "\"task\":\"Buy milk\"") != NULL);

    Response listed = handle_request(&store, "GET", "/todos", "");
    assert(strcmp(listed.status, "200 OK") == 0);
    assert(strstr(listed.body, "Buy milk") != NULL);
}

static void deletes_a_todo_and_404s_on_repeat(void) {
    Store store;
    store_init(&store);
    handle_request(&store, "POST", "/todos", "{\"task\":\"one\"}");

    Response deleted = handle_request(&store, "DELETE", "/todos/1", "");
    assert(strcmp(deleted.status, "204 No Content") == 0);

    Response deleted_again = handle_request(&store, "DELETE", "/todos/1", "");
    assert(strcmp(deleted_again.status, "404 Not Found") == 0);
}

static void rejects_a_post_with_no_task_field(void) {
    Store store;
    store_init(&store);
    Response result = handle_request(&store, "POST", "/todos", "{}");
    assert(strcmp(result.status, "400 Bad Request") == 0);
}

static void unknown_route_is_404(void) {
    Store store;
    store_init(&store);
    Response result = handle_request(&store, "GET", "/nope", "");
    assert(strcmp(result.status, "404 Not Found") == 0);
}

static void parses_a_task_field_with_an_escaped_quote(void) {
    char out[128];
    int ok = parse_task_field("{\"task\":\"say \\\"hi\\\"\"}", out, sizeof(out));
    assert(ok);
    assert(strcmp(out, "say \"hi\"") == 0);
}

static void deleting_an_invalid_id_is_a_400_not_a_crash(void) {
    Store store;
    store_init(&store);
    Response result = handle_request(&store, "DELETE", "/todos/not-a-number", "");
    assert(strcmp(result.status, "400 Bad Request") == 0);
}

static void ids_are_never_reused_after_a_delete(void) {
    Store store;
    store_init(&store);
    handle_request(&store, "POST", "/todos", "{\"task\":\"first\"}");
    handle_request(&store, "DELETE", "/todos/1", "");
    Response created = handle_request(&store, "POST", "/todos", "{\"task\":\"second\"}");
    assert(strstr(created.body, "\"id\":2") != NULL); /* not id 1 again */
}

static void list_is_ordered_by_id(void) {
    Store store;
    store_init(&store);
    handle_request(&store, "POST", "/todos", "{\"task\":\"a\"}");
    handle_request(&store, "POST", "/todos", "{\"task\":\"b\"}");
    Response listed = handle_request(&store, "GET", "/todos", "");
    const char *pos_a = strstr(listed.body, "\"a\"");
    const char *pos_b = strstr(listed.body, "\"b\"");
    assert(pos_a != NULL && pos_b != NULL && pos_a < pos_b);
}

int main(void) {
    RUN(creates_and_lists_todos);
    RUN(deletes_a_todo_and_404s_on_repeat);
    RUN(rejects_a_post_with_no_task_field);
    RUN(unknown_route_is_404);
    RUN(parses_a_task_field_with_an_escaped_quote);
    RUN(deleting_an_invalid_id_is_a_400_not_a_crash);
    RUN(ids_are_never_reused_after_a_delete);
    RUN(list_is_ordered_by_id);
    printf("All tests passed.\n");
    return 0;
}

Compile and run with gcc -std=c17 -Wall -Wextra -Wpedantic -pthread -o test_run RestApi.c test_RestApi.c && ./test_run. Because handle_request takes plain values in and returns a plain Response, every test here runs with zero sockets and zero timing concerns.

5 The Interface

Verified against a real running server with real curl requests, not just the unit tests above.

INPUTPOST /todoscreate a task
What it expects
curl -X POST :8080/todos -d '{"task":"Buy milk"}'
OUTPUTGET /todoslist tasks as JSON
What it returns
[{"id":1,"task":"Buy milk","done":false}]

6 Run It & Automate It

Save the code as RestApi.h / RestApi.c / main.c and compile it with gcc — that turns your source directly into a native executable for your machine. No separate runtime needed: the compiled binary runs on its own.

Run it locally
gcc -pthread -o server main.c RestApi.c && ./server
Starts listening on http://127.0.0.1:8080. Try it with curl in another terminal.

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.

What you should see when it works
Terminala real run
$ ./server &
Listening on http://127.0.0.1:8080
$ curl -X POST :8080/todos -d '{"task":"Buy milk"}'
{"id":1,"task":"Buy milk","done":false}
$ curl -X POST :8080/todos -d '{"task":"Write report"}'
{"id":2,"task":"Write report","done":false}
$ curl :8080/todos
[{"id":1,"task":"Buy milk","done":false},{"id":2,"task":"Write report","done":false}]
$ curl -X DELETE :8080/todos/1 -o /dev/null -w '%{http_code}\n'
204
$ curl :8080/todos
[{"id":2,"task":"Write report","done":false}]
$ curl -X DELETE :8080/todos/1 -o /dev/null -w '%{http_code}\n'
404
If it breaks — how to fix it
🚨 could not bind to :8080
Something else is already listening on port 8080 — stop it, or change PORT in main.c and your curl commands.
🚨 {"error":"missing \"task\" field"}
The POST body was not valid JSON containing a "task" key — the parser here only looks for the literal text "task":"...".
GroovyJenkinsfile
// 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 'gcc --version'
                // compile with strict warnings on
                sh 'gcc -std=c17 -Wall -Wextra -o app *.c -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.' }
    }
}
🎯 Try this next — make it yours
  1. Add a PATCH /todos/{id} route to toggle done. (Teaches: extending the route match.)
  2. Use a real JSON library such as cJSON or json-c if you have network access, and compare how much hand-rolled parsing disappears. (Teaches: what a small, focused dependency buys you.)
  3. Add request logging. Print method, path and status for every request. (Teaches: simple middleware-style wrapping.)
What you learned
You learned what a web framework does underneath: parsing a raw HTTP request off a socket, routing by method and path, and guarding shared state with a mutex across a thread-per-connection server — then proved it against a real running server with real curl requests. Related: Concurrency in C, Structs and Arrays.