Let's create a basic crud api with Rust using Tide

Some time ago I started to learn about rust-lang and after read and watch some stuffs I decided to give a try to create a basic CRUD using tide as a web framework.

This example ( and approach ) is heavy inspired in two awesome resources :

Les start by creating a new binary project.

If you don't have rust installed, please check the install page and also can check my notes about rustup and cargo.

$ cargo new tide-basic-crud && cd tide-basic-crud

Next we want to add tide as dependency since is the http framework I selected to use, we can add it by hand editing the
Cargo.toml
file or install cargo-edit and use the add command.

$ cargo install cargo-edit
$ cargo add tide
    Updating 'https://github.com/rust-lang/crates.io-index' index
      Adding tide v0.13.0 to dependencies

Now following the getting started guide of tide add async-std with the attributes feature enable by adding this line to your deps ( in Cargo.toml file ).

async-std = { version = "1.6.0", features = ["attributes"] }

And edit our main.rs with the basic example.

#[async_std::main]
async fn main() -> Result<(), std::io::Error> {
    tide::log::start();
    let mut app = tide::new();
    app.at("/").get(|_| async { Ok("Hello, world!") });
    app.listen("127.0.0.1:8080").await?;
    Ok(())
}

Now we can compile and run the server with

$ cargo run

[Running 'cargo run']
   Compiling tide-basic-crud v0.1.0 (/Users/pepo/personal/rust/tide-basic-crud)
    Finished dev [unoptimized + debuginfo] target(s) in 5.25s
     Running `target/debug/tide-basic-crud`
tide::log Logger started
    level Info
tide::listener::tcp_listener Server listening on http://127.0.0.1:8080

And check with curl

$ curl localhost:8080
Hello, world!

Awesome! we have our server up and running, but at this point doesn't do much so let's add some code. For the purpose of this example let's create a simple CRUD that allow us to track dinosaurs information.

We will recording the name, weight ( in kilograms ) and the diet ( type ).
First let's create a route for create a dinos, adding the /dinos route ( at ) with the verb post and following the request/response concept

(...)
    app.at("/dinos").post(|mut req: Request<()>| async move {
        let body = req.body_string().await?;
        println!("{:?}", body);
        let mut res = Response::new(201);
        res.set_body(String::from("created!"));
        Ok(res)
    });

And test...

$ curl -v -d '{"name":"velociraptor", "weight": 50, "diet":"carnivorous"}' http://localhost:8080/dinos

(...)
* upload completely sent off: 59 out of 59 bytes
< HTTP/1.1 201 Created
(...)
created!

And we can check the payload of the request in the logs

tide::log::middleware <-- Request received
    method POST
    path /dinos
"{\"name\":\"velociraptor\", \"weight\": 50, \"diet\":\"carnivorous\"}"
tide::log::middleware --> Response sent
    method POST
    path /dinos
    status 201
    duration 59.609µs

Nice! But we get the body as string and we need to parse as json. If you are familiarized with node.js and express this could be done with the body-parser middleware, but tide can parse json and form (urlencoded) out of the box with body_json and body_form methods.

Let's change body_string() to body_json and try again.

curl -v -d '{"name":"velociraptor", "weight": 50, "diet":"carnivorous"}' http://localhost:8080/dinos

< HTTP/1.1 422 Unprocessable Entity

422 Unprocessable Entity, doesn't works as expected (or maybe yes). Tide use serde to deserialize the request body and need to be parse into a struct. So, let's create our Dino struct and deserialize the body into.

#[derive(Debug, Deserialize, Serialize)]
struct Dino {
    name: String,
    weight: u16,
    diet: String
}

Here we use the derive attributes to all to serialize/deserialize, now let's change the route to deserialize the body into the the Dino struct and return the json representation.

app.at("/dinos").post(|mut req: Request<()>| async move {
        let  dino: Dino = req.body_json().await?;
        println!("{:?}", dino);
        let mut res = Response::new(201);
        res.set_body(Body::from_json(&dino)?);
        Ok(res)
    });

And check if works as expected...

$ curl -d '{"name":"velociraptor", "weight": 50, "diet":"carnivorous"}' http://localhost:8080/dinos

{"name":"velociraptor","weight":50,"diet":"carnivorous"}

Nice! let's move forward and store those dinos using a hashMap to store a key/value in memory. We will add a db persistence later.

Looking the tide documentation, we can use tide::with_state to create a server with shared application scoped state.

Let's use the state to store our hashMap.

#[derive(Clone,Debug)]
struct State {
    dinos: Arc<RwLock<HashMap<String,Dino>>>
}

We wrap our hashMap in a mutex here to use in the State ( thanks to the tide awesome community for the tip ).

Then in the main fn

let state = State {
        dinos: Default::default()
    };

    let mut app = tide::with_state(state);
    app.at("/").get(|_| async { Ok("Hello, world!") });

    app.at("/dinos")
       .post(|mut req: Request<State>| async move {
           let  dino: Dino = req.body_json().await?;
           // let get a mut ref of our store ( hashMap )
           let mut dinos = req.state().dinos.write().await;
           dinos.insert(String::from(&dino.name), dino.clone());
           let mut res = Response::new(201);
           res.set_body(Body::from_json(&dino)?);
          Ok(res)
     })

Nice, let's add a route to list the dinos and check how it's works

app.at("/dinos")
        .get(|req: Request<State>| async move {
            let dinos = req.state().dinos.read().await;
            // get all the dinos as a vector
            let dinos_vec: Vec<Dino> = dinos.values().cloned().collect();
            let mut res = Response::new(200);
            res.set_body(Body::from_json(&dinos_vec)?);
            Ok(res)
        })
$ curl -d '{"name":"velociraptor", "weight": 50, "diet":"carnivorous"}' http://localhost:8080/dinos

$ curl -d '{"name":"t-rex", "weight": 5000, "diet":"carnivorous"}' http://localhost:8080/dinos

$ curl http://localhost:8080/dinos

[{"name":"velociraptor","weight":50,"diet":"carnivorous"},{"name":"t-rex","weight":5000,"diet":"carnivorous"}]

Nice! now let's try getting and individual dino...

app.at("/dinos/:name")
        .get(|req: Request<State>| async move {
            let mut dinos = req.state().dinos.write().await;
            let key: String = req.param("name")?;
            let res = match dinos.entry(key) {
                Entry::Vacant(_entry) => Response::new(404),
                Entry::Occupied(entry) => {
                    let mut res = Response::new(200);
                    res.set_body(Body::from_json(&entry.get())?);
                    res
                }
            };
            Ok(res)
        })

We are using the entry api so, before using here we need to bring it to the scope. We can do it adding this line at the top of the file

use std::collections::hash_map::Entry;

Now we can match in the get to return the dino or 404 if the requested name doesn't exists.

$ curl http://localhost:8080/dinos/t-rex

{"name":"t-rex","weight":5000,"diet":"carnivorous"}

$ curl -I http://localhost:8080/dinos/trex
HTTP/1.1 404 Not Found
content-length: 0

Awesome! We are almost there, add the missing routes to complete the CRUD.

.put(|mut req: Request<State>| async move {
            let dino_update: Dino = req.body_json().await?;
            let mut dinos = req.state().dinos.write().await;
            let key: String = req.param("name")?;
            let res = match dinos.entry(key) {
                Entry::Vacant(_entry) => Response::new(404),
                Entry::Occupied(mut entry) => {
                    *entry.get_mut() = dino_update;
                    let mut res = Response::new(200);
                    res.set_body(Body::from_json(&entry.get())?);
                    res
                }
            };
            Ok(res)
        })
        .delete(|req: Request<State>| async move {
            let mut dinos = req.state().dinos.write().await;
            let key: String = req.param("name")?;
            let deleted = dinos.remove(&key);
            let res = match deleted {
                None => Response::new(404),
                Some(_) => Response::new(204),
            };
            Ok(res)
        });

Let's try update...

$ curl -v -X PUT -d '{"name":"t-rex", "weight": 5, "diet":"carnivorous"}' http://localhost:8080/dinos/t-rex

$ curl http://localhost:8080/dinos/t-rex

{"name":"t-rex","weight":5,"diet":"carnivorous"}

and delete

$ curl -v -X DELETE  http://localhost:8080/dinos/t-rex

$ curl -I http://localhost:8080/dinos/t-rex
HTTP/1.1 404 Not Found

We made it! a complete CRUD with tide. But that was a lot of manual testing, let's add some basic unit test to smooth the next steps.
First decouple our main function of the server creation allowing us to create a server without need to actually listen in any port.

#[async_std::main]
async fn main() {
    tide::log::start();
    let dinos_store = Default::default();
    let app = server(dinos_store).await;

    app.listen("127.0.0.1:8080").await.unwrap();
}

async fn server(dinos_store: Arc<RwLock<HashMap<String, Dino>>>) -> Server<State> {
    let state = State {
        dinos: dinos_store, //Default::default(),
    };

    let mut app = tide::with_state(state);
    app.at("/").get(|_| async { Ok("ok") });

(...)
app

Great, now we can add some basic test using the server function using cargo for running our tests. There is more information about tests in the cargo book but in general

Cargo can run your tests with the cargo test command. Cargo looks for tests to run in two places: in each of your src files and any tests in tests/. Tests in your src files should be unit tests, and tests in tests/ should be integration-style tests. As such, you’ll need to import your crates into the files in tests.

#[async_std::test]
async fn list_dinos() -> tide::Result<()> {
    use tide::http::{Method, Request, Response, Url};

    let dino = Dino {
        name: String::from("test"),
        weight: 50,
        diet: String::from("carnivorous"),
    };

    let mut dinos_store = HashMap::new();
    dinos_store.insert(dino.name.clone(), dino);
    let dinos: Vec<Dino> = dinos_store.values().cloned().collect();
    let dinos_as_json_string = serde_json::to_string(&dinos)?;

    let state = Arc::new(RwLock::new(dinos_store));
    let app = server(state).await;

    let url = Url::parse("https://example.com/dinos").unwrap();
    let req = Request::new(Method::Get, url);
    let mut res: Response = app.respond(req).await?;
    let v = res.body_string().await?;
    assert_eq!(dinos_as_json_string, v);
    Ok(())
}

#[async_std::test]
async fn create_dino() -> tide::Result<()> {
    use tide::http::{Method, Request, Response, Url};

    let dino = Dino {
        name: String::from("test"),
        weight: 50,
        diet: String::from("carnivorous"),
    };

    let dinos_store = HashMap::new();

    let state = Arc::new(RwLock::new(dinos_store));
    let app = server(state).await;

    let url = Url::parse("https://example.com/dinos").unwrap();
    let mut req = Request::new(Method::Post, url);
    req.set_body(serde_json::to_string(&dino)?);
    let res: Response = app.respond(req).await?;
    assert_eq!(201, res.status());
    Ok(())
}

This unit tests are inspired in this gist that was posted on the tide-users discord channel.

The main idea here is that we can create our server ( app ), calling the endpoint with a request without need to make an actual http request and have the server listen in any port.

We can now run cargo test

$  cargo test
   Compiling tide-basic-crud v0.1.0 (/Users/pepo/personal/rust/tide-basic-crud)
    Finished test [unoptimized + debuginfo] target(s) in 5.99s
     Running target/debug/deps/tide_basic_crud-3d6db2bae3cd08a5

running 5 tests
test list_dinos ... ok
test index_page ... ok
test create_dino ... ok
test delete_dino ... ok
test update_dino ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Just one more think to add, cargo is an awesome tool and you can add more functionality like watching changes in you code and react ( for example running test ) with cargo-watch.

This is all for now, my intent to learn by doing. I'm sure that there is other ways ( likely betters ) to create a crud with tide and any feedback is welcome :) .

In the next post I will refactor the code to move the dinos_store to a database ( postgresql / sqlx ).

You can find the complete code in this repo Thanks!

20