CAPI is A Plain Interface.
A minimal HTTP/1.1 web API written in C. The goal here is simplicity and little dependencies.
- Linux / POSIX (sockets,
pthreads,gmtime_r,/dev/urandom) - GCC or Clang with C11 support
forge is the project's build driver. Build it once with:
gcc -o forge forge.cand just call forge whenever you want to compile Capi.
forge shells out to:
gcc -o build/capi -g -Wall -Wextra -Icore/src -Ilibs api/api.c -pthreadRun from the repository root — the / route reads ./frontend/index.html:
./build/capiOpen http://localhost:6969 or call the endpoints with curl.
All routes are hard-coded in the block of handle_client() in
api/api.c.
Serves frontend/index.html as text/html. If the file cannot be read, returns 404.
curl http://localhost:6969/Serves frontend/capi.svg as image/svg+xml. It is the capybara mascot shown on
the index page and used as the favicon.
curl http://localhost:6969/capi.svgIt is an example of a generic GET endpoint.
Reads the value of the paramValue query parameter. If the query string has no
paramValue, the route falls back to a JSON body with a paramValue string
property (useful for GET requests sent with a body).
curl "http://localhost:6969/hasUrlParam/anything?paramValue=hello"or:
curl -X GET http://localhost:6969/hasUrlParam/anything \
-H 'Content-Type: application/json' \
-d '{"paramValue":"hello"}'It is an example of a generic POST endpoint.
Reads the bodyParam string property from the JSON request body and echoes it
back as the response body.
curl -X POST http://localhost:6969/hasBodyParam \
-H 'Content-Type: application/json' \
-d '{"bodyParam":"hello"}'A new route is just another branch.
Example for a GET /health endpoint:
case VERB_GET:
if (strcmp(res.resource, "/") == 0) {
/* serve a file */
}
else if (strcmp(res.resource, "/health") == 0) {
char *body = "{\"status\":\"ok\"}";
free_response(&res);
send_200_to_client_json(client, body, (int)strlen(body));
}
else {
send_404_to_client(client);
}
break;Key points:
- If not using the arena version, always call
free_response(&res)once you are done readingres.resource,res.queryandres.body. They are heap-allocated byread_from_client(). - Use the
send_*helpers instead of writing to the socket manually. payloadis a fixedPAYLOAD_SIZE(8 KiB) stack buffer; requests larger than that are truncated.VERB_QUANTITY_is checked withstatic_assertso adding a new verb to the enum forces you to update the switch.
Each request gets its own Arena. The arena ships inside ahb_lib.h (there is no
separate arena.h), so it is compiled together with AHB_LIB_IMPLEMENTATION:
Arena arena = {0};
arena_init(&arena);
Response res = read_from_client_arena(&arena, client, payload, PAYLOAD_SIZE);
char *page = ahb_read_entire_file_arena(&arena, "./frontend/index.html");
char *copy = arena_strdup(&arena, res.resource);
/* ... */
arena_free(&arena);Rules of thumb:
read_from_client_arena()copiesresource,queryandbodyinto the arena, so do not callfree_response()on thatResponse—arena_free()is the only cleanup needed.- The arena must be zero-initialized (
Arena arena = {0};).arena_init()is optional: a zero-initialized arena grows on demand througharena_alloc().INIT_ARENA_CAPACITY(default 4 MB) sets the first block size and can be overridden beforeAHB_LIB_IMPLEMENTATION. arena_alloc()returns memory aligned toARENA_ALIGNMENT, orNULLon failure.arena_reset()rewinds the arena so the same block can be reused, e.g. across requests handled by the same thread.- Growth uses
realloc(), so do not keep arena pointers across a call that may grow the arena. - Anything that must outlive the request (shared state, caches, maps) must use
malloc/strdup, not the arena, or it will dangle afterarena_free().
main() hands each accepted socket to a detached thread through a ClientJob
struct. Extend that struct to pass request-scoped context into handle_client():
typedef struct {
int client;
char *example;
/* add more per-connection data here */
} ClientJob;Handlers run concurrently, so any shared mutable state must be guarded. A mutex is already sketched in the file:
static pthread_mutex_t your_lock = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&your_lock);
/* read/write shared state */
pthread_mutex_unlock(&your_lock);The headers in libs/ are STB-style single-header libraries: declarations are
always visible, and the implementation is compiled only in the translation unit
that defines the matching macro before including the header.
| Header | Macro | Overview |
|---|---|---|
ahb_lib.h |
AHB_LIB_IMPLEMENTATION |
General utilities: logging, command execution, args, string builders, hash map, arena bump allocator, file helpers, temp allocator, colors |
json.h |
JSON_IMPLEMENTATION |
Minimal JSON parser with typed object/array accessors |
network.h |
NETWORK_IMPLEMENTATION |
Loopback TCP server/client, HTTP request parsing and response helpers |
Define each macro in exactly one translation unit; api.c does this for all
three. Note that network.h also defines AHB_LIB_IMPLEMENTATION, so a file
including it must not include ahb_lib.h with its implementation again.
sequenceDiagram
participant C as Client
participant M as main()
participant T as handle_client()
C->>M: TCP connect
M->>M: accept_client() + timeout setup
M->>T: pthread_create(detached) with ClientJob
T->>T: read_from_client()
T->>T: arena_init()
T->>T: route on verb + resource
T->>C: send_200/400/404/500
T->>T: free_response() + arena_free()
T->>C: close()
https://fleabag-app.duckdns.org
These are intentional for a teaching-sized framework:
- Listens only on
127.0.0.1; the address is not configurable, only the port. - Request payloads are capped at 8 KiB (
PAYLOAD_SIZE); paths and query strings are capped at 256 bytes. - Only
GETandPOSTare distinguished; any other method is treated asGET. - No TLS, no keep-alive, no chunked transfer encoding, no HTTP parsing hardening.
send_400_to_client()emits the status line400 Not Foundinstead of400 Bad Request.
Do not expose this server to a network without adding proper request limits, timeouts and TLS in front of it.
Could not load the file index.html — the server was not started from the
repository root. cd to the project root and run ./build/capi.
Could not bind the server to port 6969 — another process is using the port:
ss -ltnp | grep 6969Rebuild after editing headers — forge recompiles api/api.c, which is the
only translation unit with the library implementations, so a single
./forge api is enough.