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.
The finished API
Section titled “The finished API”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/pathGET /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 answersShort, 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 — andsqlx preparekeeps 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
towerlayer 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
tracingandtower-http. - Tests against a real Postgres. Step 14, using
sqlx::testso 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.
The route
Section titled “The route”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.
What you need first
Section titled “What you need first”- Rust, installed and current. Every step is written and tested against Rust 1.99.0 with
edition 2024 — the default
cargo newproduces 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 acrossawaitpoints. The first chapters of Ownership and borrowing are enough. Resultand?. 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.
Where the code lives
Section titled “Where the code lives”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.