Skip to main content
In this tutorial, you’ll build a complete JSON API for managing todos. You’ll learn how to create routes, controllers, and interact with the database.

What We’re Building

A REST API with these endpoints:
MethodEndpointDescription
GET/api/todosList all todos
GET/api/todos/:idGet a single todo
POST/api/todosCreate a todo
PUT/api/todos/:idUpdate a todo
DELETE/api/todos/:idDelete a todo

Prerequisites

Make sure you have a Kit project created:
kit new todo-api
cd todo-api

Step 1: Create the Migration

First, create a migration for the todos table:
kit make:migration create_todos_table
Edit the generated migration file in migrations/:
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .create_table(
                Table::create()
                    .table(Todos::Table)
                    .if_not_exists()
                    .col(
                        ColumnDef::new(Todos::Id)
                            .integer()
                            .not_null()
                            .auto_increment()
                            .primary_key(),
                    )
                    .col(ColumnDef::new(Todos::Title).string().not_null())
                    .col(ColumnDef::new(Todos::Description).text().null())
                    .col(
                        ColumnDef::new(Todos::Completed)
                            .boolean()
                            .not_null()
                            .default(false),
                    )
                    .col(ColumnDef::new(Todos::CreatedAt).timestamp().not_null())
                    .col(ColumnDef::new(Todos::UpdatedAt).timestamp().not_null())
                    .to_owned(),
            )
            .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .drop_table(Table::drop().table(Todos::Table).to_owned())
            .await
    }
}

#[derive(DeriveIden)]
enum Todos {
    Table,
    Id,
    Title,
    Description,
    Completed,
    CreatedAt,
    UpdatedAt,
}
Run the migration and sync entities:
kit migrate
kit db:sync

Step 2: Create the Controller

Create a controller for handling todo operations:
kit make:controller todos
Edit src/controllers/todos.rs:
use kit::{json_response, Request, Response};
use kit::database::{Model, ModelMut};
use crate::models::todos::{Entity as Todos, ActiveModel, Model as Todo};
use sea_orm::ActiveValue::Set;
use serde::Deserialize;

#[derive(Deserialize)]
pub struct CreateTodoRequest {
    pub title: String,
    pub description: Option<String>,
}

#[derive(Deserialize)]
pub struct UpdateTodoRequest {
    pub title: Option<String>,
    pub description: Option<String>,
    pub completed: Option<bool>,
}

// GET /api/todos
pub async fn index(_req: Request) -> Response {
    match Todos::all().await {
        Ok(todos) => json_response!({
            "data": todos,
            "count": todos.len()
        }),
        Err(e) => json_response!({
            "error": e.to_string()
        }, 500),
    }
}

// GET /api/todos/:id
pub async fn show(req: Request) -> Response {
    let id: i32 = match req.param("id").unwrap().parse() {
        Ok(id) => id,
        Err(_) => return json_response!({"error": "Invalid ID"}, 400),
    };

    match Todos::find_by_pk(id).await {
        Ok(Some(todo)) => json_response!({"data": todo}),
        Ok(None) => json_response!({"error": "Todo not found"}, 404),
        Err(e) => json_response!({"error": e.to_string()}, 500),
    }
}

// POST /api/todos
pub async fn store(req: Request) -> Response {
    let body: CreateTodoRequest = match req.json().await {
        Ok(body) => body,
        Err(_) => return json_response!({"error": "Invalid request body"}, 400),
    };

    let now = chrono::Utc::now().naive_utc();

    let new_todo = ActiveModel {
        title: Set(body.title),
        description: Set(body.description),
        completed: Set(false),
        created_at: Set(now),
        updated_at: Set(now),
        ..Default::default()
    };

    match Todos::insert_one(new_todo).await {
        Ok(result) => json_response!({
            "message": "Todo created",
            "id": result.last_insert_id
        }, 201),
        Err(e) => json_response!({"error": e.to_string()}, 500),
    }
}

// PUT /api/todos/:id
pub async fn update(req: Request) -> Response {
    let id: i32 = match req.param("id").unwrap().parse() {
        Ok(id) => id,
        Err(_) => return json_response!({"error": "Invalid ID"}, 400),
    };

    let body: UpdateTodoRequest = match req.json().await {
        Ok(body) => body,
        Err(_) => return json_response!({"error": "Invalid request body"}, 400),
    };

    // Find existing todo
    let existing = match Todos::find_by_pk(id).await {
        Ok(Some(todo)) => todo,
        Ok(None) => return json_response!({"error": "Todo not found"}, 404),
        Err(e) => return json_response!({"error": e.to_string()}, 500),
    };

    // Update fields
    let mut active: ActiveModel = existing.into();

    if let Some(title) = body.title {
        active.title = Set(title);
    }
    if let Some(description) = body.description {
        active.description = Set(Some(description));
    }
    if let Some(completed) = body.completed {
        active.completed = Set(completed);
    }
    active.updated_at = Set(chrono::Utc::now().naive_utc());

    match Todos::update_one(active).await {
        Ok(updated) => json_response!({"data": updated}),
        Err(e) => json_response!({"error": e.to_string()}, 500),
    }
}

// DELETE /api/todos/:id
pub async fn destroy(req: Request) -> Response {
    let id: i32 = match req.param("id").unwrap().parse() {
        Ok(id) => id,
        Err(_) => return json_response!({"error": "Invalid ID"}, 400),
    };

    match Todos::delete_by_pk(id).await {
        Ok(result) => {
            if result.rows_affected > 0 {
                json_response!({"message": "Todo deleted"})
            } else {
                json_response!({"error": "Todo not found"}, 404)
            }
        }
        Err(e) => json_response!({"error": e.to_string()}, 500),
    }
}

Step 3: Define Routes

Add the routes in src/routes.rs:
use kit::routes;
use crate::controllers::todos;

pub fn routes() -> Vec<kit::Route> {
    routes![
        // API Routes
        get "/api/todos" => todos::index,
        get "/api/todos/{id}" => todos::show,
        post "/api/todos" => todos::store,
        put "/api/todos/{id}" => todos::update,
        delete "/api/todos/{id}" => todos::destroy,
    ]
}

Step 4: Test the API

Start the server:
kit serve --backend-only

Create a Todo

curl -X POST http://localhost:8080/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Kit", "description": "Build awesome Rust apps"}'
Response:
{
  "message": "Todo created",
  "id": 1
}

List All Todos

curl http://localhost:8080/api/todos
Response:
{
  "data": [
    {
      "id": 1,
      "title": "Learn Kit",
      "description": "Build awesome Rust apps",
      "completed": false,
      "created_at": "2024-01-15T12:00:00",
      "updated_at": "2024-01-15T12:00:00"
    }
  ],
  "count": 1
}

Get a Single Todo

curl http://localhost:8080/api/todos/1

Update a Todo

curl -X PUT http://localhost:8080/api/todos/1 \
  -H "Content-Type: application/json" \
  -d '{"completed": true}'

Delete a Todo

curl -X DELETE http://localhost:8080/api/todos/1

Adding Validation

You can add validation using the validator crate:
use validator::Validate;

#[derive(Deserialize, Validate)]
pub struct CreateTodoRequest {
    #[validate(length(min = 1, max = 255))]
    pub title: String,
    #[validate(length(max = 1000))]
    pub description: Option<String>,
}

pub async fn store(req: Request) -> Response {
    let body: CreateTodoRequest = match req.json().await {
        Ok(body) => body,
        Err(_) => return json_response!({"error": "Invalid request body"}, 400),
    };

    // Validate
    if let Err(errors) = body.validate() {
        return json_response!({"error": "Validation failed", "details": errors}, 422);
    }

    // ... rest of the handler
}

Summary

You’ve built a complete CRUD API with:
  • Database migrations for the todos table
  • A controller with index, show, store, update, and destroy actions
  • RESTful routes following conventions
  • JSON responses with proper error handling

Next Steps

  • Add authentication middleware to protect routes
  • Implement pagination for the index endpoint
  • Add filtering and sorting capabilities
  • Create an Inertia frontend (see Inertia Todo Tutorial)