🐙 tako
Tutorials

Building a REST API

Build a small JSON REST API with path, query, and body extractors, shared state, a custom error type, and a middleware layer.

Building a REST API

This tutorial builds a small JSON REST API end-to-end. It starts from the runnable examples/extractors-multi crate, then layers on the pieces a real service needs: shared application state, a custom error type that maps to HTTP status codes, and a middleware layer.

By the end you will have:

  • GET /users/{id}/posts?page=&per_page= — path and query extraction
  • POST /users — a typed JSON body, echoed back as JSON
  • shared state read inside a handler
  • a fallible handler returning Result<Json<T>, ApiError>
  • a RequestId middleware on the router

If you have not served a handler before, read the Quickstart first.

1. The starting point

The example crate is a two-route API: one route combines a path parameter with query parameters, the other accepts a JSON body and returns JSON. This is the whole file:

use anyhow::Result;
use serde::Deserialize;
use serde::Serialize;
use tako::Method;
use tako::extractors::json::Json;
use tako::extractors::params::Params;
use tako::extractors::query::Query;
use tako::router::Router;
use tokio::net::TcpListener;

#[derive(Deserialize)]
struct Pagination {
  page: u32,
  per_page: u32,
}

#[derive(Deserialize)]
struct UserPath {
  id: u64,
}

#[derive(Deserialize, Serialize, Clone)]
struct CreateUser {
  name: String,
  email: String,
}

#[derive(Serialize)]
struct Created {
  id: u64,
  name: String,
  email: String,
}

// GET /users/{id}/posts?per_page=10&page=2
// Demonstrates multiple extractors: Params + Query
async fn list_user_posts(Params(user): Params<UserPath>, Query(p): Query<Pagination>) -> String {
  format!(
    "user_id={}, page={}, per_page={}",
    user.id, p.page, p.per_page
  )
}

// POST /users with JSON body {"name":"...","email":"..."}
// Demonstrates a body extractor
async fn create(Json(user): Json<CreateUser>) -> Json<Created> {
  // Normally you'd persist the user; here we just echo back with an id
  Json(Created {
    id: 1,
    name: user.name,
    email: user.email,
  })
}

#[tokio::main]
async fn main() -> Result<()> {
  let listener = TcpListener::bind("127.0.0.1:8080").await?;

  let mut router = Router::new();
  router.route(Method::GET, "/users/{id}/posts", list_user_posts);
  router.route(Method::POST, "/users", create);

  tako::serve(listener, router).await;

  Ok(())
}

A few things to note:

  • Each handler argument is an extractor. Params<UserPath> pulls the {id} path segment, Query<Pagination> parses the query string, and Json<CreateUser> deserializes the request body. Tako runs every extractor before calling the handler. See Extractors for the full catalog.
  • The structs deriving Deserialize are the typed targets for those extractors; the response struct derives Serialize so Json<Created> can encode it.
  • router.route(Method::GET, "/users/{id}/posts", …) registers the handler. {id} is a matchit path slot. (For compile-time-typed slots like {id: u64}, see the #[tako::get] macro family.)

2. The Cargo.toml

The example depends only on the umbrella crate plus serde and a runtime:

[dependencies]
anyhow = "1"
serde = { version = "1", features = ["derive"] }
tako-rs = "2"
tokio = { version = "1", features = ["full"] }

tako-rs is the package name; you import it as tako. The default feature set already covers HTTP/1.1, so no flags are needed yet. We add plugins later for the middleware section.

3. Adding shared state

Most APIs need to share something with every handler — a database pool, a config struct, a cache handle. Tako exposes this through the State<T> extractor backed by Router::with_state.

Define a Clone config type, register it once with with_state, then pull it into any handler by adding a State<T> argument:

use std::sync::Arc;
use tako::extractors::query::Query;
use tako::extractors::state::State;
use tako::responder::Responder;

#[derive(Clone)]
struct AppState {
  default_per_page: u32,
}

async fn list_user_posts(
  Params(user): Params<UserPath>,
  Query(p): Query<Pagination>,
  State(state): State<AppState>,
) -> impl Responder {
  let per_page = if p.per_page == 0 { state.default_per_page } else { p.per_page };
  format!("user_id={}, page={}, per_page={}", user.id, p.page, per_page)
}

Register the state on the router before serving:

let mut router = Router::new();
router.with_state(AppState { default_per_page: 20 });
router.route(Method::GET, "/users/{id}/posts", list_user_posts);

State<T> reads from the per-router store first and surfaces the value as Arc<T>. When no caller ever invoked with_state, the lookup short-circuits on a single AtomicBool::Acquire, so unused state costs nothing on the hot path.

4. A custom error type

Real handlers fail: a user is missing, a body is invalid, a downstream call errors. Tako lets a handler return Result<R, E> where both arms implement Responder — the Err arm is turned into a response just like the Ok arm. Define an error enum and give it a Responder impl that picks the status code:

use http::StatusCode;
use tako::extractors::json::Json;
use tako::responder::Responder;
use tako::types::Response;

enum ApiError {
  NotFound,
  BadRequest(String),
}

impl Responder for ApiError {
  fn into_response(self) -> Response {
    let (status, msg) = match self {
      ApiError::NotFound => (StatusCode::NOT_FOUND, "user not found".to_string()),
      ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
    };
    (status, msg).into_response()
  }
}

Now a handler can fail with a typed error and Tako renders the right status:

async fn get_user(Params(user): Params<UserPath>) -> Result<Json<Created>, ApiError> {
  if user.id == 0 {
    return Err(ApiError::BadRequest("id must be non-zero".into()));
  }
  if user.id > 1_000 {
    return Err(ApiError::NotFound);
  }
  Ok(Json(Created { id: user.id, name: "Ada".into(), email: "ada@example.com".into() }))
}

For machine-readable error bodies, call router.use_problem_json() to emit RFC 7807 application/problem+json for 4xx/5xx responses, or implement the Responder for your error type to return tako::problem::Problem directly. See the migration guide for the full error-handling surface (error_handler, client_error_handler, use_problem_json).

5. Adding a middleware layer

Cross-cutting concerns — request IDs, auth, rate limiting — attach as middleware without touching handler code. Anything implementing IntoMiddleware can be attached to the whole router or a single route. The bundled middleware lives in tako-plugins, enabled with the plugins feature.

A RequestId middleware that tags every request with an X-Request-ID header:

use tako::middleware::IntoMiddleware;
use tako::middleware::request_id::RequestId;

let mut router = Router::new();
router.middleware(RequestId::new().into_middleware());
router.route(Method::POST, "/users", create);

router.middleware(…) is global — every request passes through it. To scope a middleware to one route, chain .middleware(…) after the route(…) call:

use tako::middleware::bearer_auth::BearerAuth;

let bearer = BearerAuth::static_token("my-secret-token").into_middleware();
router
  .route(Method::POST, "/users", create)
  .middleware(bearer);

See Middleware for the full bundled set (auth, CORS, compression, rate limiting, sessions, CSRF, metrics, and more).

6. Running it

cargo run

Then exercise the routes:

# query + path
curl 'http://127.0.0.1:8080/users/7/posts?page=2&per_page=10'
# user_id=7, page=2, per_page=10

# JSON body in, JSON out
curl -X POST http://127.0.0.1:8080/users \
  -H 'content-type: application/json' \
  -d '{"name":"Ada","email":"ada@example.com"}'
# {"id":1,"name":"Ada","email":"ada@example.com"}

Next steps

On this page