Coding by Hand
Rust home

What Is an SDK

A deli counter has a menu on the wall and a person behind the glass who knows where every meat and cheese lives. You walk up, say "turkey on rye, no mustard," and the sandwich comes back. You never picked up the knife, never reached into the bread bin, never weighed the meat. An SDK is the person behind the glass for a remote service. You name the thing you want — put this file in this bucket — and the SDK reaches into the right drawers, signs the right slips, and hands you back a typed result. Strip the SDK away and you are the one slicing the bread.

An SDK acts like the worker behind a deli counter — you name the sandwich, they reach into every drawer for you.
An SDK acts like the worker behind a deli counter — you name the sandwich, they reach into every drawer for you.

The word grew up at Apple in the mid 1980s. Mac developers were drowning in low-level Toolbox calls — drawing a window meant calling QuickDraw, then the Event Manager, then the Window Manager, each with its own conventions. Apple bundled the headers, libraries, sample code, and documentation into one box called the Macintosh Programmer's Workshop and started calling the bundle a "software development kit." Sun did the same for Java a decade later. AWS shipped its first SDK in 2006 because nobody wanted to hand-sign HTTP requests with HMAC-SHA1 in seven languages. The pattern was the same every time. A service was too painful to call by hand. The owner of the service shipped a library so callers could stop bleeding.

Picture what the calling code looks like without an SDK. Talking to S3 over HTTP means building a request from scratch — picking the method, the host, the path, the headers, the body, then computing a signature over all of it.

#[derive(Clone)]
struct Request {
    method: String,
    host: String,
    path: String,
    headers: Vec<(String, String)>,
    body: Vec<u8>,
}

#[derive(Clone)]
struct Response {
    status: u16,
    headers: Vec<(String, String)>,
    body: Vec<u8>,
}

impl Request {
    fn new(method: &str, host: &str, path: &str) -> Self {
        Self {
            method: method.to_string(),
            host: host.to_string(),
            path: path.to_string(),
            headers: Vec::new(),
            body: Vec::new(),
        }
    }

    fn header(mut self, name: &str, value: &str) -> Self {
        self.headers.push((name.to_string(), value.to_string()));
        self
    }

    fn body(mut self, bytes: Vec<u8>) -> Self {
        self.body = bytes;
        self
    }
}

Request and Response are the shape of any HTTP exchange. A method like PUT or GET, a host and a path that name where the bytes are going, a list of headers that say things like "here is my auth token" and "the body is 5 bytes long," and a body that is the actual payload. The response comes back with a status number, headers of its own, and a body. Nothing in these types knows the word S3 — they would work for talking to Stripe or GitHub or any other service that speaks HTTP.

The raw client wraps those request and response types with one method — send — and a tiny in-memory store standing in for the real bucket.

struct MockS3Client {
    region: String,
    access_key: String,
    store: HashMap<String, Vec<u8>>,
    attempts: u32,
}

impl MockS3Client {
    fn new(region: &str, access_key: &str) -> Self {
        Self {
            region: region.to_string(),
            access_key: access_key.to_string(),
            store: HashMap::new(),
            attempts: 0,
        }
    }

    fn send(&mut self, req: Request) -> Response {
        self.attempts += 1;
        let key = format!("{}{}", req.host, req.path);
        match req.method.as_str() {
            "PUT" => {
                self.store.insert(key, req.body);
                Response {
                    status: 200,
                    headers: vec![("ETag".to_string(), "\"abc123\"".to_string())],
                    body: Vec::new(),
                }
            }
            "GET" => match self.store.get(&key) {
                Some(bytes) => Response {
                    status: 200,
                    headers: Vec::new(),
                    body: bytes.clone(),
                },
                None => Response {
                    status: 404,
                    headers: Vec::new(),
                    body: b"NoSuchKey".to_vec(),
                },
            },
            _ => Response {
                status: 405,
                headers: Vec::new(),
                body: b"MethodNotAllowed".to_vec(),
            },
        }
    }
}

MockS3Client keeps a region, an access key, a hash map that pretends to be the bucket, and a counter for how many requests have gone out. The real AWS SDK keeps the same shape — connection pool, credentials provider, retry counter, a list of regions to fall back to. The send method picks an arm based on the method, stores or fetches bytes, and builds a response. A real client would open a socket, write HTTP bytes, parse the response, and surface network errors. The shape is the same. The detail of "actual bytes on a wire" is what an SDK author writes once so every caller does not.

Without an SDK the caller hand-builds every HTTP field; with one, they pass three arguments.
Without an SDK the caller hand-builds every HTTP field; with one, they pass three arguments.

The raw layer works. A caller can build a Request, pass it to send, and read the Response. But every caller now has to remember the path format, the auth header name, the content-length math, and the meaning of every status code. That is the bleeding the SDK is meant to stop. So you stack a typed wrapper on top.

struct PutResult {
    etag: String,
}

struct GetResult {
    body: Vec<u8>,
}

impl MockS3Client {
    fn put_object(&mut self, bucket: &str, key: &str, body: &[u8]) -> Result<PutResult, String> {
        let host = format!("{}.s3.{}.amazonaws.com", bucket, self.region);
        let path = format!("/{}", key);
        let auth = format!("AWS4-HMAC-SHA256 Credential={}/...", self.access_key);
        let req = Request::new("PUT", &host, &path)
            .header("Authorization", &auth)
            .header("Content-Length", &body.len().to_string())
            .body(body.to_vec());
        let res = self.send(req);
        if res.status == 200 {
            let etag = res
                .headers
                .iter()
                .find(|(n, _)| n == "ETag")
                .map(|(_, v)| v.clone())
                .unwrap_or_default();
            Ok(PutResult { etag })
        } else {
            Err(format!("put failed: status {}", res.status))
        }
    }

    fn get_object(&mut self, bucket: &str, key: &str) -> Result<GetResult, String> {
        let host = format!("{}.s3.{}.amazonaws.com", bucket, self.region);
        let path = format!("/{}", key);
        let auth = format!("AWS4-HMAC-SHA256 Credential={}/...", self.access_key);
        let req = Request::new("GET", &host, &path).header("Authorization", &auth);
        let res = self.send(req);
        if res.status == 200 {
            Ok(GetResult { body: res.body })
        } else {
            Err(format!("get failed: status {}", res.status))
        }
    }
}

put_object takes the three things a caller actually cares about — the bucket, the key, the body — and hides everything else. It builds the host name from the bucket and the region, formats the path, attaches the auth header, sets the content-length, calls send, then unpacks the response into a typed PutResult that exposes only the etag. The caller never sees the URL, never sees a header, never sees a status code. They get back Ok(PutResult { etag }) or Err(String) and the compiler forces them to handle both. get_object does the same shape in reverse — typed inputs, typed result, errors as values.

This is the part the deli counter does for you. The menu says "turkey on rye." Behind the glass, the worker grabs the right loaf, slices the meat to the right thickness, weighs it, wraps it. You did not say any of that. You said the sandwich name.

An SDK stacks typed methods on top of a raw transport, with auth, retries, and serialization in between.
An SDK stacks typed methods on top of a raw transport, with auth, retries, and serialization in between.

Watch both layers run side by side.

fn show_raw() {
    let mut client = MockS3Client::new("us-east-1", "AKIAEXAMPLE");
    println!("--- raw layer ---");
    let req = Request::new("PUT", "notes.s3.us-east-1.amazonaws.com", "/hello.txt")
        .header("Authorization", "AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE/...")
        .header("Content-Length", "5")
        .body(b"hello".to_vec());
    println!("request: {} {}{}", req.method, req.host, req.path);
    for (n, v) in &req.headers {
        println!("  header: {}: {}", n, v);
    }
    println!("  body: {} bytes", req.body.len());
    let res = client.send(req);
    println!("response: status {}", res.status);
    for (n, v) in &res.headers {
        println!("  header: {}: {}", n, v);
    }
    println!("attempts so far: {}", client.attempts);
    println!();
}

fn show_typed() {
    let mut client = MockS3Client::new("us-east-1", "AKIAEXAMPLE");
    println!("--- typed wrapper ---");
    match client.put_object("notes", "hello.txt", b"hello") {
        Ok(r) => println!("put_object ok: etag={}", r.etag),
        Err(e) => println!("put_object err: {}", e),
    }
    match client.get_object("notes", "hello.txt") {
        Ok(r) => println!("get_object ok: body={:?}", String::from_utf8_lossy(&r.body)),
        Err(e) => println!("get_object err: {}", e),
    }
    match client.get_object("notes", "missing.txt") {
        Ok(r) => println!("get_object ok: {} bytes", r.body.len()),
        Err(e) => println!("get_object err: {}", e),
    }
    println!("attempts so far: {}", client.attempts);
}
--- raw layer ---
request: PUT notes.s3.us-east-1.amazonaws.com/hello.txt
  header: Authorization: AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE/...
  header: Content-Length: 5
  body: 5 bytes
response: status 200
  header: ETag: "abc123"
attempts so far: 1

--- typed wrapper ---
put_object ok: etag="abc123"
get_object ok: body="hello"
get_object err: get failed: status 404
attempts so far: 3

The raw call shows up first. Method PUT, full host name with the bucket and region baked in, the auth header that a real client would sign with HMAC-SHA256, the content-length, and the five-byte body. The response comes back with status 200 and an etag. Notice the request count after — one attempt. The caller wrote every field by hand.

The typed wrapper runs the same kind of work three times — a put, a successful get, a missing get — and the calling code is three lines. The put_object call returns the etag. The first get_object returns the bytes. The second get_object asks for a key that was never stored and the wrapper translates the 404 into an Err the caller has to handle. The attempt counter climbs to three because each typed call still goes through send underneath. The wrapper is not magic — it is the same HTTP traffic with the boring parts hidden.

A real SDK does more than wrap. AWS, Stripe, GitHub, Anthropic, and OpenAI all ship official SDKs in five to ten languages, and the list of things they add on top of send is short and consistent — authentication that signs every request with the right algorithm, retries that catch transient network errors and back off before trying again, pagination that turns a series of "next page" tokens into one iterator, serialization between language-native types and the wire format, and typed error variants so the caller can match on RateLimitExceeded instead of parsing a string. The Stripe SDK invented the modern shape in 2011 when Patrick Collison wrote stripe.Charge.create(amount: 2000) and the rest of the industry copied the pattern.

One question worth asking — why ship an SDK at all when the underlying service already has an HTTP API? The answer is that HTTP is a contract for bytes, and bytes are easy to get wrong. An SDK is a contract for types. A caller cannot pass a string where the SDK expects an integer because the compiler will not let them. A caller cannot forget to set the auth header because the SDK sets it for them. A caller cannot misspell a field name in the JSON body because the SDK serializes from a struct. Every mistake an HTTP caller could make at runtime, the SDK shifts left to compile time, and the bug never ships.

The thing this design cannot do is talk to a service it has never met. The SDK has to know the shape of every operation ahead of time — every method name, every input field, every output field. The next bottleneck is letting a program describe its own operations at runtime so an outside agent can call them without a hand-written wrapper — which is what MCP servers exist to solve.