Skip to content
Select theme

What we're building: a short link API

Step 1 of 15, and it runs nothing. This is the map: what the short link API does when the track is finished, then the fourteen steps between here and there. Read it once so every later article has a place to land — each one assumes the state the previous step left behind and adds one piece to the same service.

A web API is where most professional Rust lives. Axum for the HTTP layer and SQLx for Postgres is the stack companies actually run, and it pulls in nearly everything else the language is about: async end to end, errors that have to become HTTP status codes, SQL checked before the program starts, and a deploy story that ends in a single binary. From step 2 on, every step ends with a server you can start and a curl command that answers.

By step 15 the service speaks one contract:

POST /links {"url": "https://example.com/very/long/path"}
-> {"short": "9xK2mQz"}
GET /9xK2mQz -> 302, Location: https://example.com/very/long/path
GET /links?page=2 -> one page of stored links (needs an API key)
DELETE /links/9xK2mQz -> removes a link (needs an API key)
GET /health -> 200 when the database answers

Short, and deliberately boring. POST /links takes a URL, generates a short code, and stores the pair; the JSON that comes back is the whole point of the service. GET /<code> is the one unauthenticated, browser-facing route — a real 302 redirect, the thing a link shortener exists to do. Listing and deleting live behind an API key, because a service that lets strangers read or delete your data is not a service, it is an incident. /health is a check that touches the database, so a load balancer can tell a dead process from a process whose connection pool is dead.

Under that surface, the track builds the parts a real API needs and a tutorial usually skips:

  • SQL the compiler checks. Step 7 moves queries from runtime strings to query!, which reads the schema and fails the build when a column name or type is wrong — and sqlx prepare keeps that working in CI where no database is running.
  • Migrations as plain SQL files. Step 6. The schema lives in version control, not in someone’s head.
  • A bad URL becoming the right status code. Step 10. Validation errors turn into 400-class responses with a readable body, not a 500 and a stack trace.
  • Authentication as middleware. Step 11. One tower layer reads the API key before the handler runs, so no handler can forget to check it.
  • Rate limiting, and its honest limit. Step 12 builds a token bucket per key — and explains why a limit that lives in one process’s memory breaks the moment you run two.
  • One request id through every log line. Step 13, with tracing and tower-http.
  • Tests against a real Postgres. Step 14, using sqlx::test so each test gets its own schema and cannot corrupt another.
  • A single binary in a container. Step 15. A multi-stage Dockerfile where the final image contains one stripped executable and nothing else.

That is the whole surface. No GraphQL, no code generation framework — a service that does one job and can be put on the internet.

Fifteen steps, published in this order. Each ends at a state the next one picks up from: a service that compiles, does one more thing, and is one step closer to something you would actually deploy.

# Article What lands
2 Setting up Axum and your first route axum::serve and a /health that answers
3 Routing and extracting path and query parameters extractors, and the order they must be in
4 Returning JSON and building a response type one envelope, one IntoResponse
5 Adding SQLx and connecting to Postgres the pool, and the wrong DATABASE_URL you will write
6 Writing your first migration sqlx migrate, and why the file is plain SQL
7 Queries that the compiler checks query! versus query, and sqlx prepare
8 Inserting and reading links the two SQL paths the whole service is
9 Generating short codes and handling collisions base62, a unique index, and a retry
10 Validating input and returning proper errors from a bad URL to the right status code
11 API key authentication with middleware a tower layer, and errors from inside it
12 Rate limiting per API key a token bucket, and why one instance is not enough
13 Structured logging and request tracing one request id through every line
14 Integration tests against a real database sqlx::test and a schema per test
15 Docker, config, and deploying to production a multi-stage build and a single binary

The shape of it: steps 2–4 stand up a server that speaks JSON and needs nothing but Rust. Steps 5–8 bring in Postgres through SQLx and end with the two SQL paths — insert and resolve — that make the service a service. Steps 9–10 make the inputs trustworthy: codes that cannot collide forever, validation with the right status codes. Steps 11–13 are the cross-cutting layers every real API carries. Steps 14–15 prove it works and put it on a server.

Steps 2–4 run on a bare machine with Rust installed. From step 5 on, the track assumes Docker is running Postgres locally — one command, shown in step 5, and nothing else to install.

  • Rust, installed and current. Every step is written and tested against Rust 1.99.0 with edition 2024 — the default cargo new produces on a current toolchain. rustup.rs installs it or brings an old install up to date.
  • Ownership basics. Handlers take owned Strings and return borrowed or owned data; you will move values into futures and across await points. The first chapters of Ownership and borrowing are enough.
  • Result and ?. Every failure in this service — bad input, dead database, failed insert — has to become an HTTP response, which means errors flow through the program instead of ending it. Error handling covers the pattern the track builds on.
  • Enough async to read. The service is async from step 2. The async fundamentals and Tokio chapters are the background; you do not need them read first, because each step explains the async it uses against the code in front of you.
  • SQL, casually. The queries are small and explained, but from step 5 on you are reading SQL in every article.

No experience with Axum or SQLx assumed — that is what the track is for.

Steps that change the project commit the result to axum-sqlx-api/ in the examples repository and tag it — api-ch02 after the setup step, and so on. If your build misbehaves partway through, checking out that tag in your clone puts your tree exactly where the article left it, and a diff against your own files shows which line drifted. This article commits nothing; the first commit lands with article 2.

Step 2 starts the service for real: a new cargo project, the axum and tokio dependencies, and axum::serve behind a /health route that answers — a server you can start and curl before any database exists. The track overview keeps the full list current as articles land.