Coding by Hand
Rust home

What Is an API

Picture the order slip a waiter clips to the rail at a diner. The customer never walks into the kitchen. The cook never walks out to the table. Everything they need to say to each other has to fit on that little slip — the table number, the dish, any swaps, the time it came in — and the slip moves between two people who otherwise never meet. An API is exactly that slip, written for two programs instead of two people. One program writes a request on the slip in a format both sides agreed on. The other program reads it, does the work, and clips back a response slip with the result. Neither side ever has to know how the other is built inside.

A diner order slip clipped to a kitchen rail, the running analogy for an API request
A diner order slip clipped to a kitchen rail, the running analogy for an API request

The slip format we still use today comes out of work Roy Fielding did at UC Irvine in the late 1990s while he was helping write the rules for HTTP itself. He published his ideas as a PhD dissertation in 2000 and called the style REST. Before REST, the dominant way for two programs to talk over the network was SOAP, which wrapped every call in a long XML envelope with so much ceremony that a single hello cost a thousand bytes of header. RPC styles before that — CORBA, Java RMI — wanted you to pretend a remote function call was the same as a local one, and the pretense fell apart every time the network blinked. Fielding's bet was that the web already had a perfectly good slip format. The verb at the top of an HTTP request — GET, POST, PUT, DELETE — could carry the intent. The path could carry which thing you meant. The body could carry the details. Use what is already there. Most APIs you talk to today still copy his bet, and the few that do not — GraphQL from Facebook in 2015, gRPC from Google a year later — are reactions against parts of REST, not replacements for the slip itself.

A request slip has four boxes on it. The verb tells the kitchen what kind of work to do. The path tells the kitchen which dish. The headers carry the small notes that do not fit anywhere else. The body carries the dish itself when there is one. A response slip has its own four boxes, in almost the same shape — a status number instead of a verb, the same headers, and a body with whatever the kitchen has to hand back.

The four parts of an HTTP request and an HTTP response, drawn as two parallel envelopes
The four parts of an HTTP request and an HTTP response, drawn as two parallel envelopes
struct Request {
    method: String,
    path: String,
    headers: Vec<(String, String)>,
    body: String,
}

struct Response {
    status: u16,
    headers: Vec<(String, String)>,
    body: String,
}

The two structs are deliberately boring. A Request is a method, a path, a list of header pairs, and a body string. A Response is a status number, a list of header pairs, and a body string. Nothing in either struct knows about the network. Nothing knows about TCP or sockets or how the bytes get from one machine to another. That is the point — the slip is just data, and the same slip works whether the cook is across the room or across the planet.

A handler is the cook reading the slip and deciding what plate to send back. It is a function from one slip to the other.

fn handler(req: &Request) -> Response {
    match (req.method.as_str(), req.path.as_str()) {
        ("GET", path) if path.starts_with("/users/") => {
            let id = &path["/users/".len()..];
            let body = format!("{{\"id\":{},\"name\":\"Aarit\"}}", id);
            Response {
                status: 200,
                headers: vec![
                    ("Content-Type".into(), "application/json".into()),
                    ("Content-Length".into(), body.len().to_string()),
                ],
                body,
            }
        }
        ("POST", "/users") => {
            let body = format!("{{\"created\":true,\"echo\":{}}}", req.body);
            Response {
                status: 201,
                headers: vec![
                    ("Content-Type".into(), "application/json".into()),
                    ("Content-Length".into(), body.len().to_string()),
                ],
                body,
            }
        }
        _ => {
            let body = String::from("{\"error\":\"not found\"}");
            Response {
                status: 404,
                headers: vec![
                    ("Content-Type".into(), "application/json".into()),
                    ("Content-Length".into(), body.len().to_string()),
                ],
                body,
            }
        }
    }
}

Look at the match arm by arm. A GET to /users/something reads the something as an id, builds a tiny JSON record, and ships it back with status 200 and a Content-Type header that tells the caller the body is JSON and not raw text. A POST to /users reads the body the caller sent, echoes it inside a created: true envelope, and ships it back with status 201 — the code that means "I made the thing you asked me to make." Anything else falls through to a 404 with a short error body. Three cases, three plates, no surprises.

The verbs split the work by intent. GET is read-only — the kitchen will not change anything, so the caller can ask the same question twice and get the same plate twice. POST creates something new. PUT replaces a thing that already exists. DELETE removes it. The whole catalog of HTTP verbs fits on a single index card, and the discipline of using the right one is what lets the rest of the web — caches, browsers, proxies — make smart guesses about which requests are safe to retry and which are not.

The status numbers split the answer by who is responsible when things go wrong, which is the same blame-attribution trick a good restaurant manager uses when a plate comes back. Anything in the 200s means the kitchen did its job. The 300s mean the dish you asked for moved to a new table. The 400s mean the customer wrote the slip wrong — bad path, missing field, no permission. The 500s mean the kitchen itself broke and the slip was fine. A caller that respects the four ranges can decide on its own whether to retry, to show the user an error, or to give up and page somebody at 3 AM.

HTTP status code ranges grouped by who is responsible when something goes wrong
HTTP status code ranges grouped by who is responsible when something goes wrong

To see a request and a response sitting next to each other on the rail, render both slips as text in the exact shape they travel in on the wire.

fn render_request(req: &Request) -> String {
    let mut out = String::new();
    out.push_str(&format!("{} {} HTTP/1.1\n", req.method, req.path));
    for (k, v) in &req.headers {
        out.push_str(&format!("{}: {}\n", k, v));
    }
    out.push('\n');
    out.push_str(&req.body);
    out
}

fn render_response(res: &Response) -> String {
    let mut out = String::new();
    let reason = match res.status {
        200 => "OK",
        201 => "Created",
        404 => "Not Found",
        _ => "Unknown",
    };
    out.push_str(&format!("HTTP/1.1 {} {}\n", res.status, reason));
    for (k, v) in &res.headers {
        out.push_str(&format!("{}: {}\n", k, v));
    }
    out.push('\n');
    out.push_str(&res.body);
    out
}

The format is the one HTTP itself uses, more or less. First line is the verb and path and protocol version for a request, or the protocol version and status and reason phrase for a response. After that come the header lines, one per line, each a Name: value pair. A blank line marks the end of the headers. Everything after the blank line is the body. The whole protocol is plain text you could type by hand into a phone line in 1995 and a server would still understand you, which is exactly how Tim Berners-Lee designed it at CERN so it could not die from one company's bad decision.

Drive both example slips through the handler and watch what comes out.

fn show_get() {
    let req = Request {
        method: "GET".into(),
        path: "/users/42".into(),
        headers: vec![
            ("Host".into(), "api.example.com".into()),
            ("Accept".into(), "application/json".into()),
        ],
        body: String::new(),
    };
    let res = handler(&req);
    println!("--- GET /users/42 ---");
    println!("request:");
    println!("{}", render_request(&req));
    println!();
    println!("response:");
    println!("{}", render_response(&res));
    println!();
}

fn show_post() {
    let req = Request {
        method: "POST".into(),
        path: "/users".into(),
        headers: vec![
            ("Host".into(), "api.example.com".into()),
            ("Content-Type".into(), "application/json".into()),
        ],
        body: String::from("{\"name\":\"Aarit\"}"),
    };
    let res = handler(&req);
    println!("--- POST /users ---");
    println!("request:");
    println!("{}", render_request(&req));
    println!();
    println!("response:");
    println!("{}", render_response(&res));
}
--- GET /users/42 ---
request:
GET /users/42 HTTP/1.1
Host: api.example.com
Accept: application/json



response:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 24

{"id":42,"name":"Aarit"}

--- POST /users ---
request:
POST /users HTTP/1.1
Host: api.example.com
Content-Type: application/json

{"name":"Aarit"}

response:
HTTP/1.1 201 Created
Content-Type: application/json
Content-Length: 40

{"created":true,"echo":{"name":"Aarit"}}

Read the GET slip first. The caller writes GET /users/42 HTTP/1.1 on the top line, lists a Host header so the server knows which site it is talking to, and an Accept header that asks for JSON. The body is empty because a GET has nothing to send. The handler matches the path, parses out the 42, builds the response slip with status 200, fills in the Content-Type and Content-Length headers so the caller knows what is coming and how much, and ships back a one-line JSON body. The whole conversation is less than 200 bytes.

The POST slip is a little fatter. The caller writes POST /users HTTP/1.1, sets the Content-Type to application/json so the kitchen knows how to read the body, and sends {"name":"Aarit"} as the body itself. JSON — JavaScript Object Notation — is the most common body format on the modern web because Douglas Crockford pulled it out of JavaScript in 2001 as a lighter replacement for the XML that SOAP loved. The handler reads the body, builds a 201 Created response with an echo of what came in, and ships it back. Status 201 instead of 200 because something new exists on the server now, and a caller who reads status numbers can tell the difference between "here is what was already there" and "I just made this."

One question worth asking — why does the handler include a Content-Length header on every response when the body is right there at the bottom of the slip? The reason is the way HTTP travels on the wire. The two sides of a real connection do not get a tidy delimited message handed to them. They get a stream of bytes, and they need to know exactly where one response ends and the next one starts. The Content-Length header tells the reader to grab the next N bytes as the body and stop. Without it, the only ways to find the end of the body are to close the connection — which kills the speed of every request after it — or to use a more complicated chunked encoding. The header is the cheap fix and almost everyone uses it.

The thing this in-memory handler cannot do on its own is talk to anyone outside the program — the request and response only exist as Rust structs in this binary, and a real client across the network would never see them. The next bottleneck is sitting on a TCP port, parsing the bytes a browser actually sends, and writing the bytes a browser actually expects back — which is what axum exists to do.