Backend Engineering · Phase 1 · Module 3
REST API Design
Design APIs that are intuitive, evolvable, and production-ready — before writing a single line of server code.
REST Constraints Resource Modelling HTTP Methods Status Codes Pagination RFC 7807 Versioning C/libcurl
🏛️

REST: Architectural Style, Not a Protocol

ROY FIELDING, 2000

REST (Representational State Transfer) was defined by Roy Fielding in his 2000 PhD dissertation. It is a set of architectural constraints on how a distributed hypermedia system should behave — not a spec, not a library, not a protocol.

Most "REST APIs" in the wild only partially implement these constraints. Understanding the full model lets you make deliberate trade-offs rather than accidental ones.

The 6 REST Constraints

🏛️ Analogy: REST is like a well-designed postal system. You write a self-contained letter (stateless request) addressed to a specific location (resource URL). The postal network (layered system) routes it without the sender knowing the path. The letter format is standardised (uniform interface). Replies can be archived (cacheable). The sender and postal network are independent organisations (client–server).

Richardson Maturity Model

The RMM measures how thoroughly an HTTP API adheres to REST. Most production APIs should aim for Level 2.

Level 0 — Swamp of POX
Single endpoint. Everything is POST. XML or JSON payload defines the action. Essentially RPC over HTTP.

POST /api {"action":"getUser","id":1}
Level 1 — Resources
Multiple endpoints, one per resource type. Still using POST for all operations but URL encodes the noun.

POST /users/1 {"action":"get"}
Level 2 — HTTP Verbs
Correct HTTP methods (GET/POST/PUT/DELETE) + correct status codes. The industry standard for "REST API".

GET /users/1 → 200 OK
Level 3 — HATEOAS
Hypermedia links in responses guide clients through available actions. Self-documenting, evolvable. Rare in practice.

{"id":1, "_links":{"self":"/users/1","posts":"/users/1/posts"}}

Practical takeaway: Design to Level 2 consistently. Add HATEOAS only if you have clients you cannot coordinate deployments with (public APIs, third-party integrations). The stateless constraint is the most important to enforce — sessions stored on the server destroy horizontal scaling.

🏗️

Resources Are Nouns, Not Verbs

CORE PRINCIPLE

The single biggest mistake in URL design is using verbs. In REST, HTTP methods are the verbs. Your URLs should identify things (resources) — nouns. The action is expressed by the combination of HTTP method + URL.

✗ DON'T — Verb URLs (RPC style)
POST /getUser?id=42 POST /createUser POST /deleteUser?id=42 POST /getUserPosts?userId=42 GET /fetchAllOrders POST /cancelOrder?orderId=5
✓ DO — Noun URLs (REST style)
GET /users/42 POST /users DELETE /users/42 GET /users/42/posts GET /orders DELETE /orders/5

URL Naming Conventions

RuleGoodBadWhy
Use plural nouns/users, /orders/user, /getOrderConsistent regardless of count; collections are plural
Lowercase, hyphen-separated/blog-posts/blogPosts, /BlogPostsURLs are case-sensitive; hyphens improve readability
No file extensions/users/42/users/42.jsonUse Accept header for content negotiation
No trailing slashes/users/42/users/42/Trailing slash implies a directory; inconsistency causes 404s
IDs in path for single resources/users/42/users?id=42Path params for identity, query params for filtering
Hierarchy mirrors relationships/users/42/posts/posts?author=42Either works; use nesting max 2 levels deep

Collections vs Items

URL PatternRepresentsTypical Operations
/usersCollection of all usersGET (list), POST (create)
/users/42Single user with id=42GET, PUT, PATCH, DELETE
/users/42/postsPosts belonging to user 42GET (list), POST (create)
/users/42/posts/7Post 7 by user 42GET, PUT, PATCH, DELETE

Nesting: When to Stop

📐

Maximum 2 Levels of Nesting

DESIGN RULE

Deep nesting creates URLs that are hard to remember, hard to construct, and tightly coupled to your data model. After 2 levels, use query parameters or a flat URL with filter params.

✗ Too Deep
/orgs/5/teams/3/members/42/posts/7/comments/2
✓ Flatten at 2 Levels
/comments/2 # or /posts/7/comments/2

The resource /comments/2 is unambiguous — comment IDs are globally unique. The parent context is available from the comment's own post_id field in the response.

Special Actions That Don't Fit the Resource Model

Action Sub-Resources

PRAGMATISM

Some operations are inherently procedural and don't map cleanly to CRUD on a resource: "send a password reset email", "archive a conversation", "retry a failed payment". The pragmatic approach is to use an action sub-resource with POST.

ActionREST-ish URLMethod
Send password reset/users/42/password-resetPOST
Archive a post/posts/7/archivePOST
Publish a draft/posts/7/publishPOST
Retry payment/payments/9/retryPOST
Transfer ownership/orgs/5/ownership-transfersPOST (creates a transfer resource)

Note: "archive" creates an archived state, "publish" creates a publication event — these can be modelled as resource state transitions triggered by POST. The verb form (/archive) is tolerated here because the action sub-resource approach is the least bad option.

Query Parameters: Filtering, Sorting, Searching

OperationConventionExample
Filter by field?field=valueGET /users?status=active&role=admin
Sort?sort=field, ?sort=-field (minus = desc)GET /users?sort=-created_at
Full-text search?q=term or ?search=termGET /posts?q=kubernetes
Sparse fieldsets?fields=f1,f2GET /users/42?fields=id,name,email
Embedded relations?include=rel1,rel2GET /posts/7?include=author,comments
Paginationsee Req & Resp tabGET /users?page=2&per_page=20

Safety and Idempotency

RFC 9110

Two properties of HTTP methods govern how clients, proxies, and servers can safely retry and cache requests. Violating these contracts breaks the entire HTTP caching and reliability infrastructure.

Safe

A method is safe if it does not alter server state. Clients, crawlers, and prefetch logic can freely call safe methods without side effects. GET, HEAD, OPTIONS, TRACE are safe.

Idempotent

A method is idempotent if calling it N times has the same effect as calling it once. Safe methods are inherently idempotent. PUT and DELETE are idempotent but not safe.

Method Reference

MethodSafeIdempotentHas BodySemantics
GET No Retrieve resource or collection. Parameters via query string only. Responses should be cacheable.
HEAD No Same as GET but response body omitted. Used to check existence, get ETag, or measure response size without downloading.
POST Yes Create a new resource in a collection. Server assigns the ID. Returns 201 Created with Location header pointing to the new resource.
PUT Yes Full replacement of a resource. Client sends the complete representation. If resource doesn't exist, create it (optional). Idempotent — sending same request twice has same result.
PATCH ❌*Yes Partial update. Only the specified fields are modified. Not inherently idempotent (a {"views": {"op":"increment"}} patch is not). JSON Merge Patch (RFC 7396) or JSON Patch (RFC 6902) for structured updates.
DELETE Rare Delete a resource. First call returns 200/204; repeat calls should return 404 (or 204 again for strict idempotency). Soft-delete returns 200 with archived representation.
OPTIONS No Returns allowed methods for a URL. Used by browsers in CORS preflight. Also useful for API discovery.

*PATCH can be made idempotent by using a conditional update with If-Match: "etag-value" — the server only applies the patch if the current ETag matches, preventing lost updates.

PUT vs PATCH: Choosing the Right One

🔄

PUT — Full Replacement

Client must send the entire resource. Missing fields are set to null/default, not preserved.

PUT /users/42 Content-Type: application/json { "name": "Alice", "email": "alice@example.com", "role": "admin" } // ALL fields required — omitting // "role" would clear it

Use when: client owns the full resource and wants a clean replacement.

✏️

PATCH — Partial Update

Client sends only the fields to change. Other fields are preserved.

PATCH /users/42 Content-Type: application/merge-patch+json { "email": "newemail@example.com" } // Only email updated; name // and role unchanged

Use when: only a subset of fields should change, or partial updates are the main use case.

JSON Merge Patch vs JSON Patch

FormatRFCMechanismBest for
JSON Merge PatchRFC 7396Merge the patch object into the resource. Set field to null to delete it.Simple field updates, human-readable patches
JSON PatchRFC 6902Array of operations: add, remove, replace, move, copy, test. Operates on JSON Pointer paths.Complex mutations, array element updates, transactional multi-field updates
// JSON Patch (RFC 6902) example
[
  { "op": "replace", "path": "/email", "value": "new@example.com" },
  { "op": "add",     "path": "/tags/-", "value": "premium" },
  { "op": "remove",  "path": "/legacy_id" },
  { "op": "test",    "path": "/version", "value": 3 }  // fails patch if version != 3
]

Idempotency Keys for POST

🔑

Making POST Safe to Retry

DISTRIBUTED SYSTEMS

POST is not idempotent — retrying a failed payment POST could charge twice. The solution is an idempotency key: a client-generated UUID sent with the request. The server stores the key + response, and on retry returns the cached response without re-executing.

POST /payments Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 Content-Type: application/json { "amount": 9900, "currency": "USD", "card_id": "card_abc" }

Stripe, PayPal, and most payment APIs use this pattern. Idempotency key storage: Redis or DB table with TTL of 24 hours is standard.

🔢

Status Codes Are Semantic Contracts

RFC 9110

Returning the wrong status code breaks client retry logic, caching, monitoring dashboards, and on-call alerting. Always use the most specific code available. Never return 200 for errors ("200 with an error body" is a Level 0 anti-pattern).

2xx — Success

CodeNameUse when
200OKGET, PUT, PATCH succeeded. Return the updated representation in body.
201CreatedPOST created a new resource. Include Location: /resources/new-id header. Optionally return the created resource.
202AcceptedRequest accepted but processing not yet complete (async job queued). Return a job/status resource URL.
204No ContentDELETE succeeded, or PATCH/PUT with no response body needed. No body — not even {}.
206Partial ContentResponse is a range of a larger resource (used with Range header for resumable downloads).

3xx — Redirection

CodeNameUse when
301Moved PermanentlyResource has a new permanent URL. Clients and search engines should update their bookmarks. Method may change to GET.
302FoundTemporary redirect. Often misused as permanent redirect. Browsers change POST → GET.
303See OtherAfter a POST (create), redirect client to the created resource with GET. The Post/Redirect/Get pattern.
304Not ModifiedConditional GET (with If-None-Match or If-Modified-Since) — cached version is still fresh. No body.
307Temporary RedirectLike 302 but method is preserved. POST → same POST at new URL.
308Permanent RedirectLike 301 but method is preserved. POST → same POST at new URL permanently.

4xx — Client Errors

CodeNameUse when
400Bad RequestRequest is malformed, missing required fields, or fails validation. Include error details in body.
401UnauthorizedNot authenticated. Despite the name, means "please authenticate". Include WWW-Authenticate header.
403ForbiddenAuthenticated but not authorised. Client identity is known; the action is not permitted for this identity.
404Not FoundResource doesn't exist. Also used to hide existence of protected resources (instead of 403).
405Method Not AllowedHTTP method not supported for this URL. Include Allow: GET, POST header listing supported methods.
409ConflictState conflict — duplicate key, version mismatch (optimistic locking), or concurrent modification.
410GoneResource existed but was permanently deleted. Use when you want clients/crawlers to remove cached reference.
422Unprocessable EntityWell-formed request but semantic validation failed (e.g., start_date > end_date). Distinguished from 400 (malformed).
429Too Many RequestsRate limit exceeded. Include Retry-After and X-RateLimit-* headers.

5xx — Server Errors

CodeNameUse when
500Internal Server ErrorUnhandled exception, bug, or unexpected state. Never expose stack traces. Log internally, return safe error message.
502Bad GatewayUpstream server (DB, microservice) returned an invalid response. Set by proxies/load balancers.
503Service UnavailableServer temporarily overloaded or in maintenance. Include Retry-After header. Clients should back off.
504Gateway TimeoutUpstream server didn't respond in time. Set by proxies/load balancers. Distinguished from 503.

⚠️ The 401 vs 403 trap: 401 = "I don't know who you are, please authenticate". 403 = "I know who you are, but you can't do this". Never return 403 to an unauthenticated request — it reveals that the resource exists and is protected. Prefer 401 or 404 depending on whether you want to expose existence.

Content Negotiation

📋

Accept & Content-Type

RFC 9110

HTTP allows clients and servers to negotiate representation format without changing the URL. Use the standard headers — don't invent /api/users.json URL-based formats.

HeaderDirectionMeaning
AcceptClient → ServerFormats the client can handle, by preference: Accept: application/json, application/xml;q=0.8
Content-TypeBothFormat of the body being sent: Content-Type: application/json
Accept-LanguageClient → ServerPreferred language for localised responses
Accept-EncodingClient → ServerCompression formats supported: Accept-Encoding: gzip, deflate, br

If the server cannot satisfy the Accept header, return 406 Not Acceptable.

Pagination Strategies

📄

Three Pagination Patterns

SCALABILITY

Offset Pagination

GET /users?page=3&per_page=20 // SQL: LIMIT 20 OFFSET 40

Pros: Simple, random access to any page, total count available.

Cons: Inconsistent results when items are inserted/deleted during pagination (items shift). Slow for high offsets (DB scans all preceding rows). Not recommended past page 100.

Cursor Pagination

GET /users?after=cursor_abc&limit=20 // SQL: WHERE id > :cursor LIMIT 20

Pros: Stable under mutations (no shifting). O(1) regardless of page depth. The only scalable strategy for high-volume feeds.

Cons: No random access (must walk from start). No total count without a separate query.

Keyset Pagination (Time-based)

GET /events?before=2026-03-26T10:00:00Z&limit=50 // SQL: WHERE created_at < :before ORDER BY created_at DESC LIMIT 50

Variant of cursor pagination using a meaningful timestamp or composite key instead of an opaque cursor. Natural for time-series data (activity feeds, logs, notifications).

Pagination Response Envelope

HTTP/1.1 200 OK Content-Type: application/json Link: </users?after=cursor_xyz>; rel="next", </users>; rel="first" { "data": [ { "id": 1, "name": "Alice", "email": "alice@example.com" }, { "id": 2, "name": "Bob", "email": "bob@example.com" } ], "pagination": { "next_cursor": "cursor_xyz", "has_more": true, "per_page": 20 } }

Use the Link header (RFC 5988) for hypermedia pagination links — this is the standard HTTP way. The response body pagination object is a convenience for clients that don't parse headers. Provide both.

Response Envelope vs Bare Resources

Bare Resource (simple)

{ "id": 42, "name": "Alice", "email": "alice@example.com" }

Clean, minimal. Standard for single-resource responses. Client knows it's talking to /users/42.

Envelope (for collections)

{ "data": [...], "meta": { "total": 1234, "page": 2 }, "links": { "next": "/users?page=3", "prev": "/users?page=1" } }

Wraps collection with metadata. JSON:API and HAL both use envelope patterns.

Caching with ETags

💾

Conditional Requests

PERFORMANCE

ETags (entity tags) enable cache validation and optimistic locking. The server returns an ETag (hash of resource content) with each response. Clients send it back on subsequent requests.

// First request GET /users/42 HTTP/1.1 200 OK ETag: "abc123" Cache-Control: max-age=60 // Conditional GET (after cache expires) GET /users/42 If-None-Match: "abc123" HTTP/1.1 304 Not Modified ← no body, saves bandwidth // Conditional UPDATE (optimistic locking) PATCH /users/42 If-Match: "abc123" // → 412 Precondition Failed if someone else modified it first

RFC 7807 — Problem Details

📄

Structured Error Responses

RFC 7807

RFC 7807 defines a standard JSON format for HTTP API error responses. Adopting it means clients can parse errors predictably, and monitoring tools can aggregate them by type.

Content-Type: application/problem+json

FieldTypeRequiredDescription
typeURIYesA URI identifying the problem type. Should resolve to human-readable documentation. Use about:blank if no docs exist.
titlestringYesShort, human-readable summary. Should not change between occurrences of the same problem type.
statusintegerYesHTTP status code. Must match the response status.
detailstringNoHuman-readable explanation specific to this occurrence.
instanceURINoURI identifying this specific occurrence. Can be used to look up a log entry or support ticket.

RFC 7807 Examples

Validation Error (400/422)

HTTP/1.1 422 Unprocessable Entity Content-Type: application/problem+json { "type": "https://api.example.com/errors/validation-error", "title": "Validation Failed", "status": 422, "detail": "Request body contains invalid fields.", "instance": "/requests/req_abc123", "errors": [ { "field": "email", "code": "INVALID_FORMAT", "message": "Must be a valid email address" }, { "field": "age", "code": "OUT_OF_RANGE", "message": "Must be between 0 and 150" } ] }

Not Found (404)

HTTP/1.1 404 Not Found Content-Type: application/problem+json { "type": "https://api.example.com/errors/resource-not-found", "title": "Resource Not Found", "status": 404, "detail": "User with id 42 does not exist.", "instance": "/requests/req_def456" }

Rate Limit (429)

HTTP/1.1 429 Too Many Requests Retry-After: 30 X-RateLimit-Limit: 100 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1711447230 { "type": "https://api.example.com/errors/rate-limit-exceeded", "title": "Rate Limit Exceeded", "status": 429, "detail": "100 requests per minute limit reached. Retry after 30 seconds." }

API Versioning Strategies

🔖

When and How to Version

EVOLVABILITY

You need versioning when a change is breaking — removes a field, changes a field type, alters semantics, removes an endpoint. Additive changes (new optional fields, new endpoints) are non-breaking and don't require a new version.

StrategyExampleProsCons
URL path /v1/users, /v2/users Explicit, easily visible in logs and browsers, cacheable URL changes break bookmarks/hardcoded URLs
Query parameter /users?version=2 URL stable, easy to test in browser Easy to forget, pollutes query string, cache complications
Accept header Accept: application/vnd.api+json;version=2 Purist REST, URL unchanged, proper content negotiation Hard to test in browser, verbose, poorly supported by API gateways
Custom header API-Version: 2026-03-01 URL stable, Stripe-style date versioning is explicit Non-standard header, requires documentation

Industry recommendation: URL path versioning (/v1/, /v2/) is the most widely adopted. It is explicit, easy to understand, and works with every HTTP client. Stripe uses date-based header versioning successfully, but they have exceptional API docs — for most teams, URL versioning is safer. Never version endpoints individually — version the whole API.

Deprecation Process

HTTP Request Parsing in C (Minimal)

#include <string.h>
#include <stdio.h>

typedef struct {
    char method[16];
    char path[256];
    char version[16];
    char host[256];
    char content_type[64];
    int  content_length;
    char body[8192];
} http_request_t;

int parse_request(const char *raw, http_request_t *req) {
    /* Parse request line: "METHOD /path HTTP/1.1\r\n" */
    if (sscanf(raw, "%15s %255s %15s",
               req->method, req->path, req->version) != 3)
        return -1;

    /* Parse headers line by line */
    const char *p = strstr(raw, "\r\n") + 2;
    req->content_length = 0;

    while (p && *p != '\r') {
        if (strncasecmp(p, "Host: ", 6) == 0)
            sscanf(p + 6, "%255[^\r]", req->host);
        else if (strncasecmp(p, "Content-Type: ", 14) == 0)
            sscanf(p + 14, "%63[^\r]", req->content_type);
        else if (strncasecmp(p, "Content-Length: ", 16) == 0)
            sscanf(p + 16, "%d", &req->content_length);
        p = strstr(p, "\r\n");
        if (p) p += 2;
    }

    /* Body starts after \r\n\r\n */
    const char *body_start = strstr(raw, "\r\n\r\n");
    if (body_start && req->content_length > 0) {
        body_start += 4;
        int len = req->content_length < (int)sizeof(req->body) - 1
                  ? req->content_length
                  : (int)sizeof(req->body) - 1;
        memcpy(req->body, body_start, len);
        req->body[len] = '\0';
    }
    return 0;
}

⚠️ This is for learning only. Production HTTP parsing is complex: chunked encoding, multipart bodies, header folding, request smuggling defences, binary content. Use a battle-tested HTTP parser library (llhttp — the Node.js HTTP parser, or picohttpparser for C) in production code.

HTTP Response Builder in C

#include <stdio.h>
#include <string.h>
#include <time.h>

typedef struct {
    int  status;
    char content_type[64];
    char body[65536];
    int  body_len;
} http_response_t;

int build_response(http_response_t *resp, char *out, int out_size) {
    const char *status_text;
    switch (resp->status) {
        case 200: status_text = "OK";                    break;
        case 201: status_text = "Created";               break;
        case 204: status_text = "No Content";             break;
        case 400: status_text = "Bad Request";            break;
        case 404: status_text = "Not Found";              break;
        case 422: status_text = "Unprocessable Entity";   break;
        case 500: status_text = "Internal Server Error"; break;
        default:  status_text = "Unknown";
    }

    /* RFC 7231 date format */
    char date_buf[64];
    time_t now = time(NULL);
    strftime(date_buf, sizeof(date_buf),
             "%a, %d %b %Y %H:%M:%S GMT", gmtime(&now));

    int n = snprintf(out, out_size,
        "HTTP/1.1 %d %s\r\n"
        "Content-Type: %s\r\n"
        "Content-Length: %d\r\n"
        "Date: %s\r\n"
        "Connection: keep-alive\r\n"
        "\r\n",
        resp->status, status_text,
        resp->content_type,
        resp->body_len,
        date_buf);

    if (n + resp->body_len >= out_size) return -1;
    memcpy(out + n, resp->body, resp->body_len);
    return n + resp->body_len;
}

/* Helper: respond with JSON */
void respond_json(int fd, int status, const char *json) {
    http_response_t resp = {
        .status    = status,
        .body_len  = strlen(json)
    };
    strncpy(resp.content_type, "application/json", sizeof(resp.content_type));
    memcpy(resp.body, json, resp.body_len);

    char out[66000];
    int len = build_response(&resp, out, sizeof(out));
    if (len > 0) send(fd, out, len, MSG_NOSIGNAL);
}

/* Helper: RFC 7807 problem response */
void respond_problem(int fd, int status, const char *type,
                      const char *title, const char *detail) {
    char body[1024];
    snprintf(body, sizeof(body),
        "{"
        "\"type\":\"%s\","
        "\"title\":\"%s\","
        "\"status\":%d,"
        "\"detail\":\"%s\""
        "}",
        type, title, status, detail);

    http_response_t resp = { .status = status };
    strncpy(resp.content_type, "application/problem+json", sizeof(resp.content_type));
    resp.body_len = strlen(body);
    memcpy(resp.body, body, resp.body_len);

    char out[66000];
    int len = build_response(&resp, out, sizeof(out));
    if (len > 0) send(fd, out, len, MSG_NOSIGNAL);
}

Consuming a REST API with libcurl (C)

#include <curl/curl.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>

/* Compile: gcc rest_client.c -lcurl -o rest_client */

typedef struct { char *data; size_t len; } buf_t;

static size_t write_cb(void *ptr, size_t sz, size_t nmemb, buf_t *b) {
    size_t n = sz * nmemb;
    b->data = realloc(b->data, b->len + n + 1);
    memcpy(b->data + b->len, ptr, n);
    b->len += n;
    b->data[b->len] = '\0';
    return n;
}

/* GET /users/42 with Bearer token */
int get_user(const char *base_url, int user_id, const char *token) {
    CURL *curl = curl_easy_init();
    if (!curl) return -1;

    char url[256];
    snprintf(url, sizeof(url), "%s/users/%d", base_url, user_id);

    buf_t resp = {NULL, 0};

    /* Auth header */
    char auth[512];
    snprintf(auth, sizeof(auth), "Authorization: Bearer %s", token);
    struct curl_slist *hdrs = curl_slist_append(NULL, auth);
    hdrs = curl_slist_append(hdrs, "Accept: application/json");

    curl_easy_setopt(curl, CURLOPT_URL, url);
    curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp);

    CURLcode rc = curl_easy_perform(curl);
    long http_code = 0;
    curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);

    if (rc == CURLE_OK) {
        printf("HTTP %ld\n%s\n", http_code, resp.data);
    } else {
        fprintf(stderr, "curl error: %s\n", curl_easy_strerror(rc));
    }

    free(resp.data);
    curl_slist_free_all(hdrs);
    curl_easy_cleanup(curl);
    return rc == CURLE_OK ? 0 : -1;
}

/* POST /users with JSON body */
int create_user(const char *base_url, const char *json_body) {
    CURL *curl = curl_easy_init();
    if (!curl) return -1;

    char url[256];
    snprintf(url, sizeof(url), "%s/users", base_url);

    buf_t resp = {NULL, 0};
    struct curl_slist *hdrs = curl_slist_append(NULL, "Content-Type: application/json");
    hdrs = curl_slist_append(hdrs, "Accept: application/json");

    curl_easy_setopt(curl, CURLOPT_URL, url);
    curl_easy_setopt(curl, CURLOPT_POST, 1L);
    curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_body);
    curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp);

    CURLcode rc = curl_easy_perform(curl);
    long http_code = 0;
    curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);

    if (rc == CURLE_OK) {
        printf("HTTP %ld\n%s\n", http_code, resp.data);
        /* 201: check Location header for new resource URL */
    }

    free(resp.data);
    curl_slist_free_all(hdrs);
    curl_easy_cleanup(curl);
    return rc == CURLE_OK ? (int)http_code : -1;
}

Simple Router in C

typedef void (*handler_fn)(int fd, http_request_t *req);

typedef struct {
    const char *method;
    const char *path_prefix;
    handler_fn  handler;
} route_t;

void handle_get_users  (int fd, http_request_t *req);
void handle_get_user   (int fd, http_request_t *req);
void handle_create_user(int fd, http_request_t *req);

static route_t routes[] = {
    { "GET",    "/users/", handle_get_user   },  /* /users/42 */
    { "GET",    "/users",  handle_get_users  },  /* /users    */
    { "POST",   "/users",  handle_create_user},
    { NULL,     NULL,      NULL              }
};

void dispatch(int fd, http_request_t *req) {
    for (int i = 0; routes[i].method; i++) {
        if (strcmp(req->method, routes[i].method) == 0 &&
            strncmp(req->path, routes[i].path_prefix,
                    strlen(routes[i].path_prefix)) == 0) {
            routes[i].handler(fd, req);
            return;
        }
    }
    /* 404 fallthrough */
    respond_problem(fd, 404,
        "about:blank", "Not Found",
        "The requested resource does not exist.");
}

🔬 Lab 1 — Design a REST API (Paper Exercise)

TOOLS: pen & paper / whiteboard

Goal: Practice resource modelling before writing code. Design the URL structure for a simple task management API.

Domain: Users create projects. Projects contain tasks. Tasks have comments. Tasks can be assigned to users.

1List all the resources: users, projects, tasks, comments, assignments. For each, define the collection URL and item URL.
2Define the full CRUD operations for each resource. For each operation write: METHOD /path → STATUS and describe the request body and response body.
3Identify 3 operations that don't map cleanly to CRUD (e.g., "mark task complete", "reassign all tasks when user leaves"). Design action sub-resources for them.
4Design the error responses for: creating a task in a project you don't own (403), creating a task with missing required fields (422), listing tasks for a non-existent project (404). Write full RFC 7807 bodies for each.
5Add pagination to GET /projects/:id/tasks. Choose between offset and cursor pagination and justify your choice. Design the full response envelope including headers.

🔬 Lab 2 — Build a REST API Server in C

TOOLS: gcc · curl · jq

Goal: Build a minimal in-memory REST API for the Users resource using the TCP server and HTTP helpers from this module.

1Start with the TCP echo server from M01 Lab 2. Replace the echo logic with parse_request() + dispatch().
2Implement an in-memory user store: user_t users[MAX_USERS] with fields id, name, email. Implement handle_get_users to return all users as a JSON array.
3Implement handle_create_user: parse the JSON body (use simple sscanf or hand-roll a tiny parser), validate required fields (400/422 on failure), assign an auto-increment ID, return 201 with Location: /users/:id.
4Implement handle_get_user: extract the ID from the path (/users/42), look up in the store, return 200 with user JSON or 404 RFC 7807 problem.
5Test with curl:
curl -s -X POST http://localhost:8080/users -H "Content-Type: application/json" -d '{"name":"Alice","email":"a@b.com"}' | jq
curl -s http://localhost:8080/users | jq
curl -s http://localhost:8080/users/1 | jq
curl -s http://localhost:8080/users/999 | jq
6Challenge: Add DELETE /users/:id returning 204, and PATCH /users/:id for partial updates. Add an If-Match ETag check to PATCH — return 412 if ETag doesn't match the current version hash.

🔬 Lab 3 — Consume a Public REST API with libcurl

TOOLS: gcc · libcurl · jq

Goal: Write a C program that consumes the GitHub REST API to list your own repositories.

1Install libcurl: sudo apt install libcurl4-openssl-dev (Debian) or brew install curl (macOS).
2Use the create_user / get_user libcurl code as a template. Call GET https://api.github.com/users/YOUR_USERNAME/repos?per_page=5&sort=updated. Set header User-Agent: my-rest-client/1.0 (GitHub requires this).
3Print the HTTP status code and raw JSON response body.
4Parse the JSON manually or with a tiny library (jsmn — single-file, no deps). Extract and print each repo's name and stargazers_count.
5Handle pagination: read the Link header from the response. Extract the rel="next" URL and fetch the next page. Print all repos across pages.
6Stretch: Add a GitHub personal access token via Authorization: Bearer TOKEN. Verify you can now see private repos.

🔬 Lab 4 — Test Your API with Behaviour-Driven Tests

TOOLS: bash · curl · jq · diff

Goal: Write a shell test suite for your REST server from Lab 2. Practice test-first API design thinking.

1Create test_api.sh. Start with a helper function assert_status(url, method, expected_code) using curl's -o /dev/null -s -w "%{http_code}" output.
2Test happy paths: POST creates user (201), GET /users returns array (200), GET /users/:id returns user (200), DELETE /users/:id returns 204.
3Test error paths: POST with missing email (422), GET /users/9999 (404), DELETE /users/9999 (404). Verify each returns the correct status AND a Content-Type: application/problem+json header.
4Test idempotency: call DELETE /users/:id twice. The first returns 204, the second should return 404. Call GET /users/:id twice — both must return the same body (verify with diff).

Module Mastery Checklist

M03 COMPLETE

You have mastered this module when you can check off every item below without referring to notes.

REST Fundamentals

Resource Design

HTTP Methods

Status Codes

Request & Response Design

Errors & Versioning

Implementation


Next modules in Phase 1: M04 covers OpenAPI specification and gRPC — moving from documentation by convention to contract-first design. M05 covers GraphQL — a fundamentally different query model that solves over-fetching and under-fetching at the cost of caching complexity.

← M01 DNS/TCP/TLS Phase 1 · Module 3 of 5 ↑ Roadmap