Guide - Hexilee/roa GitHub Wiki

This blog will guide you to start a simple api application from scratch.

Let's start!

# Cargo.toml

[dependencies]
roa = "0.5.0"
async-std = { version = "1.5", features = ["attributes"] }
// main.rs

use roa::App;
use roa::preload::*;
use std::error::Error;
use std::result::Result as StdResult;

#[async_std::main]
async fn main() -> StdResult<(), Box<dyn Error>> {
    let app = App::new().end("Hello, World");
    app.listen("127.0.0.1:8000", |addr| {
        println!("Server is listening on {}", addr)
    })?
    .await?;
    Ok(())
}

Now we build a hello world application. Execute cargo run, then curl 127.0.0.1:8000, we will get:

Hello, World

It works!

Add a data transfer object.

A DTO(data transfer object), is an object that can be serialized or deserialized to transfer data between application.

Now, let's add serde to our dependencies:

serde = { version = "1", features = ["derive"] }

Then define a DTO type User and transfer its instance in json.

use roa::{App, Context, Result};
use roa::preload::*;
use std::error::Error;
use std::result::Result as StdResult;
use serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize)]
struct User {
    id: u64,
    name: String,
}

async fn hello(ctx: &mut Context) -> Result {
    let user = User {
        id: 0,
        name: "Hexilee".to_owned(),
    };
    ctx.write_json(&user)
}

#[async_std::main]
async fn main() -> StdResult<(), Box<dyn Error>> {
    let app = App::new().end(hello);
    app.listen("127.0.0.1:8000", |addr| {
        println!("Server is listening on {}", addr)
    })?
    .await?;
    Ok(())
}

Now let's run it, and curl 127.0.0.1:8000 again, we will get:

{"id":0,"name":"Hexilee"}

What's the next step?

Receive uploaded DTO

We now serialize an object and transfer it to client successfully.

But how to receive an object uploaded by client? Just use ctx.read_json.

use roa::{App, Context, Result};
use roa::preload::*;
use std::error::Error;
use std::result::Result as StdResult;
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct User {
    id: u64,
    name: String,
}

async fn hello(ctx: &mut Context) -> Result {
    let user: User = ctx.read_json().await?;
    ctx.write_json(&user)
}

#[async_std::main]
async fn main() -> StdResult<(), Box<dyn Error>> {
    let app = App::new().end(hello);
    app.listen("127.0.0.1:8000", |addr| {
        println!("Server is listening on {}", addr)
    })?
    .await?;
    Ok(())
}

Now let's run it, and execute:

> curl -H "Content-type: application/json" -d '{"id":0, "name":"Hexilee"}' 127.0.0.1:8000
{"id":0, "name":"Hexilee"}

It works. We can start to design our api.

Design API

What apis should our application support? It depends on the functions we need.

For a simple CRUD(create, retrieve, update, delete) application, at least four interfaces are needed. And I would like to follow the restful api style.

So we will implement these interfaces:

  • POST /user, create a new user, DTO is transferred by body, in the format of JSON.
  • GET /user/:id, get data of a user by a unique id.
  • PUT /user/:id, update data of a user by a unique id and data in body.
  • DELETE /user/:id, delete a user by a unique id.

Now, let's start to implement them!

Router

To deal with URI path automatically by config, we need a router.

use roa::{App, Context, Result};
use roa::preload::*;
use roa::router::{Router, get, post};
use std::error::Error;
use std::result::Result as StdResult;
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct User {
    id: u64,
    name: String,
}

async fn get_user(ctx: &mut Context) -> Result {
    let id = ctx.must_param("id")?.parse()?;
    let user = User {
        id,
        name: "Hexilee".to_owned(),
    };
    ctx.write_json(&user)
}

async fn create_user(ctx: &mut Context) -> Result {
    let user: User = ctx.read_json().await?;
    ctx.write_json(&user)
}

#[async_std::main]
async fn main() -> StdResult<(), Box<dyn Error>> {
    let router = Router::new()
    	.on("/", post(create_user))
    	.on("/:id", get(get_user));
    let app = App::new().end(router.routes("/user")?);
    app.listen("127.0.0.1:8000", |addr| {
        println!("Server is listening on {}", addr)
    })?
    .await?;
    Ok(())
}

:id means a dynamic path with named variable "id" in roa::router, we can use ctx.param or ctx.must_param to get value of the variable.

> curl 127.0.0.1:8000/user/0
{"id":0,"name":"Hexilee"}

Set router successfully.

Database

To implement the interfaces truly, we need a database to store data.

There are many database and ORM libraries in rust ecosystem, although, I decide to pick a simpler one.

slab = "0.4.2"

slab::Slab is a collections in memory, we will implement a memory database based on it.

use roa::{throw, App, Context, Result};
use roa::http::StatusCode;
use roa::preload::*;
use roa::router::Router;
use serde::{Deserialize, Serialize};
use serde_json::json;
use slab::Slab;
use std::sync::Arc;
use async_std::sync::RwLock;

#[derive(Debug, Clone, Serialize, Deserialize)]
struct User {
    id: u64,
    name: String,
}

#[derive(Clone)]
struct Database {
    table: Arc<RwLock<Slab<User>>>,
}

impl Database {
    fn new() -> Self {
        Self {
            table: Arc::new(RwLock::new(Slab::new())),
        }
    }

    // create a new user and return id.
    async fn create(&self, user: User) -> usize {
        self.table.write().await.insert(user)
    }

    // search for a user by id, 
    // return 404 NOT FOUND if not exists.
    async fn retrieve(&self, id: usize) -> Result<User> {
        match self.table.read().await.get(id) {
            Some(user) => Ok(user.clone()),
            None => throw!(StatusCode::NOT_FOUND),
        }
    }

    // update a user by id,
    // swap new data and old data.
    // return 404 NOT FOUND if not exists.
    async fn update(&self, id: usize, new_user: &mut User) -> Result {
        match self.table.write().await.get_mut(id) {
            Some(user) => {
                std::mem::swap(new_user, user);
                Ok(())
            }
            None => throw!(StatusCode::NOT_FOUND),
        }
    }

    // delete a user by id, 
    // return 404 NOT FOUND if not exists.
    async fn delete(&self, id: usize) -> Result<User> {
        if !self.table.read().await.contains(id) {
            throw!(StatusCode::NOT_FOUND)
        }
        Ok(self.table.write().await.remove(id))
    }
}

You can skip this code because database implementing job does not belong to a web backend developer. However, you should have a look at the comments to know the functions of these methods, otherwise you may feel confused when we use them.

Implement API

use roa::{Context, Result};

async fn get_user(ctx: &mut Context) -> Result {
    unimplemented!()
}

async fn create_user(ctx: &mut Context) -> Result {
    unimplemented!()
}

async fn update_user(ctx: &mut Context) -> Result {
    unimplemented!()
}

async fn delete_user(ctx: &mut Context) -> Result {
    unimplemented!()
}

These are definition of the four interfaces. Now we meet a problem: how to pass a database instance to these functions? Create a global static database?

Roa framework provides context state to resolve this problem.

State

State is an alias for 'static + Clone + Send + Sync. Our database is exactly a state.

use roa::{App, Context, Result};

#[derive(Clone)]
struct Database {
    // ignore definition
}

impl Database {
    fn new() -> Self {
        // ignore definition
        Self{}
    }
}

// state is a database.
async fn create_user(ctx: &mut Context<Database>) -> Result {
    unimplemented!()
}

async fn get_user(ctx: &mut Context<Database>) -> Result {
    unimplemented!()
}

async fn update_user(ctx: &mut Context<Database>) -> Result {
    unimplemented!()
}

async fn delete_user(ctx: &mut Context<Database>) -> Result {
    unimplemented!()    
}

let app = App::state(Database::new());

Each time a new context would be constructed, the app.state will be cloned and delivered in context.

Now we can access database by context:

use roa::{throw, App, Context, Result};
use roa::http::StatusCode;
use roa::preload::*;
use serde::{Deserialize, Serialize};
use serde_json::json;

#[derive(Debug, Clone, Serialize, Deserialize)]
struct User {
    id: u64,
    name: String,
}

#[derive(Clone)]
struct Database {
    // ignore definition
}

impl Database {
    fn new() -> Self {
        Self{}
    }

    // create a new user and return id.
    async fn create(&self, user: User) -> usize {
		unimplemented!()
    }
}

async fn create_user(ctx: &mut Context<Database>) -> Result {
    let user: User = ctx.read_json().await?;
    let id = ctx.create(user).await;
    ctx.write_json(&json!({ "id": id }))?;
    ctx.resp.status = StatusCode::CREATED;
    Ok(())
}

Complete

Following is the complete code:

use async_std::sync::{Arc, RwLock};
use roa::http::StatusCode;
use roa::preload::*;
use roa::router::{get, post, Router};
use roa::{throw, App, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::json;
use slab::Slab;
use std::error::Error;
use std::result::Result as StdResult;

#[derive(Debug, Serialize, Deserialize, Clone)]
struct User {
    name: String,
    age: u8,
}

#[derive(Clone)]
struct Database {
    table: Arc<RwLock<Slab<User>>>,
}

impl Database {
    fn new() -> Self {
        Self {
            table: Arc::new(RwLock::new(Slab::new())),
        }
    }

    async fn create(&self, user: User) -> usize {
        self.table.write().await.insert(user)
    }

    async fn retrieve(&self, id: usize) -> Result<User> {
        match self.table.read().await.get(id) {
            Some(user) => Ok(user.clone()),
            None => throw!(StatusCode::NOT_FOUND),
        }
    }

    async fn update(&self, id: usize, new_user: &mut User) -> Result {
        match self.table.write().await.get_mut(id) {
            Some(user) => {
                std::mem::swap(new_user, user);
                Ok(())
            }
            None => throw!(StatusCode::NOT_FOUND),
        }
    }

    async fn delete(&self, id: usize) -> Result<User> {
        if !self.table.read().await.contains(id) {
            throw!(StatusCode::NOT_FOUND)
        }
        Ok(self.table.write().await.remove(id))
    }
}

async fn create_user(ctx: &mut Context<Database>) -> Result {
    let user: User = ctx.read_json().await?;
    let id = ctx.create(user).await;
    ctx.write_json(&json!({ "id": id }))?;
    ctx.resp.status = StatusCode::CREATED;
    Ok(())
}

async fn get_user(ctx: &mut Context<Database>) -> Result {
    let id: usize = ctx.must_param("id")?.parse()?;
    let user = ctx.retrieve(id).await?;
    ctx.write_json(&user)
}

async fn update_user(ctx: &mut Context<Database>) -> Result {
    let id: usize = ctx.must_param("id")?.parse()?;
    let mut user: User = ctx.read_json().await?;
    ctx.update(id, &mut user).await?;
    ctx.write_json(&user)
}

async fn delete_user(ctx: &mut Context<Database>) -> Result {
    let id: usize = ctx.must_param("id")?.parse()?;
    let user = ctx.delete(id).await?;
    ctx.write_json(&user)
}

#[async_std::main]
async fn main() -> StdResult<(), Box<dyn Error>> {
    let router = Router::new()
        .on("/", post(create_user))
        .on("/:id", get(get_user).put(update_user).delete(delete_user));
    let app = App::state(Database::new()).end(router.routes("/user")?);
    app.listen("127.0.0.1:8000", |addr| {
        println!("Server is listening on {}", addr)
    })?
    .await?;
    Ok(())
}

You can notice that I replace field id with age in User, because User::id means nothing in our api, id should be passed by router parameter.

Let's run the final application and test it!

> curl 127.0.0.1:8000/user/0

Get nothing because we have never created a user.

> curl -H "Content-type: application/json" -d '{"name":"Hexilee", "age": 20}' -X POST 127.0.0.1:8000/user
{"id":0}

We create one user successfully, his id is 0.

> curl 127.0.0.1:8000/user/0
{"name":"Hexilee","age":20}

Nice! Let's update it:

> curl -H "Content-type: application/json" -d '{"name":"Alice", "age": 20}' -X PUT 127.0.0.1:8000/user/0
{"name":"Hexilee","age":20}

We get the old data, which means updating action is successful.

Try to get again:

> curl 127.0.0.1:8000/user/0
{"name":"Alice","age":20}

Delete it now:

> curl 127.0.0.1:8000/user/0 -X DELETE
{"name":"Alice","age":20}

Getting old data means succeeding to delete it.

> curl 127.0.0.1:8000/user/0

Get nothing now.

Afterword

The guide ends, if you are still interested in roa framework, please refer to the Cookbook.

You are welcome to open an issue for any problems or advices.

⚠️ **GitHub.com Fallback** ⚠️