DEV Community

Cover image for Building Modern Web Services in C with Papago
Brian Downs
Brian Downs

Posted on

Building Modern Web Services in C with Papago

If you've spent any time building web services in C, you know the pain.

libpapago takes a different approach from other web frameworks in C. It's a modern, full-featured C web framework designed to be genuinely simple to use.. If you can read a route handler in Express or Go, you can read a route handler in papago.

Let's dig in.


Why C for Web Services?

Before getting into the framework, it's worth asking the obvious question. Modern C web services make a lot of sense in the right contexts:

  • Embedded systems and IoT where runtime footprint matters
  • High-performance infrastructure where you need predictable, low-latency behavior
  • Systems-adjacent APIs that live close to hardware or OS interfaces
  • Extending existing C codebases without introducing a language boundary

The problem has never been C's ability to serve HTTP. The problem has been ergonomics. That's what papago is designed to solve.


Getting Started

Dependencies

libpapago has a deliberately short dependency list:

  • libmicrohttpd — battle-tested embedded HTTP server
  • libwebsockets — WebSocket server and client
  • openssl — TLS support
  • libmaple — the Maple template engine (optional, for HTML templating)
apt install libmicrohttpd-dev libwebsockets-dev libssl-dev
Enter fullscreen mode Exit fullscreen mode

On FreeBSD via pkg:

pkg install libmicrohttpd libwebsockets openssl
Enter fullscreen mode Exit fullscreen mode

If HTML/text templating is needed:

git clone https://github.com/briandowns/libmaple
cd libmaple
make && sudo make install
Enter fullscreen mode Exit fullscreen mode

Build and Install

make PAPAGO_USE_MAPLE=1
sudo make install PAPAGO_USE_MAPLE=1
Enter fullscreen mode Exit fullscreen mode

Let's take a look at an extremely simple example. When initializing the papago config, it sets reasonable defaults. For example, runs on port 8080, thread pool set to 4, and a body size of 10MB, albeit on the higher end of normal. All of the values are overridable.

#include <stdio.h>
#include "papago.h"

void
hello_handler(papago_request_t *req, papago_response_t *res, void *user_data)
{
    PAPAGO_UNUSED(req);
    PAPAGO_UNUSED(user_data);

    papago_res_json(res, "{\"message\":\"Hello, World!\"}");
}

int
main(void)
{
    papago_t *server = papago_new();

    papago_config_t config = papago_default_config();
    papago_configure(server, &config);

    papago_route(server, PAPAGO_GET, "/hello", hello_handler, NULL);

    papago_start(server); // blocking

    papago_destroy(server);

    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Build and run:

cc -o hello hello.c papago.c -lwebsockets -lmicrohttpd -lz -lm -lpthread
./hello
Enter fullscreen mode Exit fullscreen mode

Testing:

curl http://localhost:8080/hello
# {"message":"Hello, World!"}
Enter fullscreen mode Exit fullscreen mode

RESTful Routing

Routing is where papago really shines for its simplicity. Full CRUD looks like this:

papago_route(server, PAPAGO_GET, "/users", list_users, NULL);
papago_route(server, PAPAGO_POST, "/users", create_user,  NULL);
papago_route(server, PAPAGO_GET, "/users/:id", get_user, NULL);
papago_route(server, PAPAGO_PUT, "/users/:id", update_user, NULL);
papago_route(server, PAPAGO_DELETE, "/users/:id", delete_user,  NULL);
Enter fullscreen mode Exit fullscreen mode

Wildcard Routes

Useful for API versioning prefixes or catch-all handlers:

papago_route(server, PAPAGO_GET, "/api/v1/*", api_v1_handler, NULL);
Enter fullscreen mode Exit fullscreen mode

Path Parameters

Dynamic segments are extracted with papago_req_param():

void
retrieve_id(papago_request_t *req, papago_response_t *res, void *user_data)
{
    const char *id = papago_req_param(req, "id");
    // fetch user by id, build response...
}
Enter fullscreen mode Exit fullscreen mode

Query Parameters

void
search_handler(papago_request_t *req, papago_response_t *res, void *user_data)
{
    const char *q = papago_req_query(req, "q");
    const char *page = papago_req_query(req, "page");
    // paginated search...
}
Enter fullscreen mode Exit fullscreen mode

Request & Response API

The request/response model is consistent throughout the framework:

void
handler(papago_request_t *req, papago_response_t *res, void *user_data)
{
    // read the request
    const char *content_type = papago_req_header(req, "Content-Type");
    const char *id = papago_req_param(req, "id");
    const char *search = papago_req_query(req, "q");
    const char *body = papago_req_body(req);

    // send the response
    papago_res_status(res, PAPAGO_STATUS_OK);
    papago_res_header(res, "X-Request-ID", "abc-123");
    papago_res_json(res, "{\"status\":\"ok\"}");
}
Enter fullscreen mode Exit fullscreen mode

Middleware

Middleware in papago is composed of two functions, a required before hook and an optional after hook, assigned to a papago_middleware_t struct.

static bool
auth_mw(papago_request_t *req, papago_response_t *res, void *user_data)
{
    const char *token = papago_req_header(req, "Authorization");
    if (!token || !validate_token(token)) {
        papago_res_status(res, PAPAGO_STATUS_UNAUTHORIZED);
        papago_res_json(res, "{\"error\":\"unauthorized\"}");
        return false; // short-circuit, don't call the route handler
    }
    return true;
}
Enter fullscreen mode Exit fullscreen mode
papago_middleware_t auth_mw = {
    .before    = auth_mw,
    .after     = NULL,
    .user_data = NULL,
};
Enter fullscreen mode Exit fullscreen mode

You can attach middleware globally or scope it to a specific path prefix:

// runs on every request
papago_middleware_add(server, auth_mw);

// runs only on /api/* routes
papago_middleware_path_add(server, "/api", auth_mw);
Enter fullscreen mode Exit fullscreen mode

The before return value is the key design point here: returning false short-circuits processing and sends the response immediately, which makes writing authentication and validation middleware clean and explicit.


Websocket Support

papago has first-class WebSocket support with a clean event callback model. You register a set of handlers for the connection lifecycle:

void
ws_on_connect(papago_ws_connection_t *conn)
{
    printf("Client connected: %s\n", papago_ws_get_client_ip(conn));
    papago_ws_send(conn, "{\"type\":\"welcome\"}");
}

void
ws_on_message(papago_ws_connection_t *conn, const char *message,
              size_t length, bool is_binary)
{
    // Echo back to sender
    papago_ws_send(conn, message);

    // Or broadcast to all connected clients
    papago_ws_broadcast(papago_get_current_server(), message);
}

void
ws_on_close(papago_ws_connection_t *conn)
{
    printf("client disconnected\n");
}

void
ws_on_error(papago_ws_connection_t *conn, const char *error)
{
    fprintf(stderr, "error: %s\n", error);
}

// Register the endpoint
papago_ws_endpoint(server, "/ws",
    ws_on_connect, ws_on_message, ws_on_close, ws_on_error);
Enter fullscreen mode Exit fullscreen mode

The JavaScript client side is standard:

const ws = new WebSocket('ws://localhost:8081/ws');

ws.onopen = () => ws.send('Hello!');
ws.onmessage = (e) => console.log('Received:', e.data);
Enter fullscreen mode Exit fullscreen mode

WebSocket Client

papago also ships a WebSocket client (papago_wsc), which is included when you build with PAPAGO_WITH_WSC=1. This is particularly useful when your server needs to connect to external WebSocket services like pub/sub brokers, monitoring streams, or service-to-service communication.


File Streaming

For serving large files, video, audio, binary downloads, papago uses zero-copy streaming with automatic MIME type detection. The full set of supported types spans everything you'd expect for a web service:

Category Types
Text/Web HTML, CSS, JS, JSON, XML, TXT
Images PNG, JPG, GIF, SVG, ICO, WebP
Video MP4, WebM, OGG
Audio MP3, WAV, M4A
Documents/Archives PDF, ZIP, TAR, GZ
Fonts WOFF, WOFF2, TTF

No manual Content-Type header setting required.

Examples can be found here.


Rate Limiting

Per-IP rate limiting is built in, no middleware to write yourself and no external library. This is particularly valuable for public-facing APIs where you want basic protection without pulling in a full API gateway.

Setup rate-limiting by registering the rate-limiting middlware like any other middleware and calling papag_enable_rete_limit with max requests and window.

The example below registers the build-in middleware and sets a max request of 5 requests in a 30 second window.

papago_middleware_t rate_limit_mw = {
    .before    = rate_limit_middleware,
    .after     = NULL,
    .user_data = NULL,
};
papago_enable_rate_limit(server, 5, 30);
papago_middleware_path_add(server, "/", &rate_limit_mw);
Enter fullscreen mode Exit fullscreen mode

Gzip Compression

Response compression is handled transparently. Clients that advertise Accept-Encoding: gzip receive compressed responses, with no code changes required in your handlers.

Enable compression in the config:

papago_config_t config = papago_default_config();
config.enable_compression = true;
Enter fullscreen mode Exit fullscreen mode

Prometheus Metrics

Observability is a first-class feature. A single route registration exposes a Prometheus-compatible /metrics endpoint:

papago_route(server, PAPAGO_GET, "/metrics", papago_metrics_handler, NULL);
Enter fullscreen mode Exit fullscreen mode

Get Involved

libpapago is BSD-2-Clause licensed and actively developed. The examples directory covers the full range of features including basic routing, SSL, WebSockets, middleware, and more.

GitHub: github.com/briandowns/libpapago
Twitter: @bdowns328

If you're building C services and have been waiting for a framework that doesn't make you write everything from scratch, give it a look. PRs are welcome.

Top comments (0)