Skip to content
Li Chenxi edited this page Feb 14, 2020 · 15 revisions

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

Let's start!

# Cargo.toml

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

use roa::core::App;
use roa::preload::*;

#[async_std::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut app = App::new(());
    app.end(|ctx| async move {
        ctx.write_text("Hello, World").await
    });
    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 get:

Hello, World

It works! Then, what's the next step?

Add a data transfer object.

A DTO(data transfer object), is an object that can be serialized and 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::core::App;
use roa::preload::*;
use serde::{Deserialize, Serialize};

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

#[async_std::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut app = App::new(());
    app.end(|ctx| async move {
        let user = User {
            id: 0,
            name: "Hexilee".to_string(),
        };
        ctx.write_json(&user).await
    });
    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 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.

use roa::core::App;
use roa::preload::*;
use serde::{Deserialize, Serialize};

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

#[async_std::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut app = App::new(());
    app.end(|ctx| async move {
        let user: User = ctx.read().await?;
        assert_eq!(0, user.id);
        assert_eq!("Hexilee", user.name);
        ctx.write_text("You are welcome.").await
    });
    app.listen("127.0.0.1:8000", |addr| {
        println!("Server is listening on {}", addr)
    })?
    .await?;
    Ok(())
}

The read method can deserialize body automatically by "Content-Type", it only supports JSON and url-encoded form currently.

Now let's run it, and execute:

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

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::core::App;
use roa::preload::*;
use roa::router::Router;
use serde::{Deserialize, Serialize};

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

#[async_std::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut app = App::new(());
    let mut router = Router::<()>::new();
    router.post("/", |ctx| async move {
        let user: User = ctx.read().await?;
        assert_eq!(0, user.id);
        assert_eq!("Hexilee", user.name);
        ctx.write_text("You are welcome.").await
    });

    router.get("/:id", |ctx| async move {
        let id: u64 = ctx.must_param("id").await?.parse()?;
        let user = User {
            id,
            name: "Hexilee".to_string(),
        };
        ctx.write_json(&user).await
    });

    router.put("/:id", |ctx| async move {
        let id: u64 = ctx.must_param("id").await?.parse()?;
        let user: User = ctx.read().await?;
        unimplemented!()
    });

    router.delete("/:id", |ctx| async move {
        let id: u64 = ctx.must_param("id").await?.parse()?;
        unimplemented!()
    });

    app.gate(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.

Now we implement all interfaces, however, the true functions are unimplemented.

> 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::core::{throw, App, Context, Model, Result, StatusCode};
use roa::preload::*;
use roa::router::Router;
use serde::{Deserialize, Serialize};
use serde_json::json;
use slab::Slab;
use std::result::Result as StdResult;

#[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 error 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 error 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 error 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

Now let's refactor code slightly, lambda is convenient but hard to read when code is more complex.

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

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

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

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

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

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

Roa framework provides two solutions, we will use the simpler one to resolve this problem.

Model

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

struct AppModel {
    db: Database,
}

impl Model for AppModel {
    type State = Database;
    fn new_state(&self) -> Self::State {
        self.db.clone()
    }
}

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

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

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

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

// receive model to construct App<AppModel>
let mut app = App::new(AppModel{ db: Database::new() });

Each time a new context would be constructed, the app.model.new_state will be invoked to generate new state and pass it by context.

Model and State are both () by default, but we need a custom model and state to access database this time.

Now we can get database by ctx.state().await:

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

Complete

Following is the complete code:

use async_std::sync::{Arc, RwLock};
use roa::core::{throw, App, Context, Model, Result, StatusCode};
use roa::preload::*;
use roa::router::Router;
use serde::{Deserialize, Serialize};
use serde_json::json;
use slab::Slab;
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))
    }
}

struct AppModel {
    db: Database,
}

impl Model for AppModel {
    type State = Database;
    fn new_state(&self) -> Self::State {
        self.db.clone()
    }
}

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

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

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

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

#[async_std::main]
async fn main() -> StdResult<(), Box<dyn std::error::Error>> {
    let mut app = App::new(AppModel {
        db: Database::new(),
    });
    let mut router = Router::new();
    router
        .post("/", create_user)
        .get("/:id", get_user)
        .put("/:id", update_user)
        .delete("/:id", delete_user);
    app.gate(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
{"name":"Alice","age":20}
> 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.

Clone this wiki locally