DEV Community

loading...

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

Javier Viola
I'm a systems engineer and open source enthusiast who loves to learn new things, resolve challenges and build cool things.
Originally published at javierviola.com Updated on ・8 min read

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"] }
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And check with curl

$ curl localhost:8080
Hello, world!
Enter fullscreen mode Exit fullscreen mode

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)
    });

Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
    });
Enter fullscreen mode Exit fullscreen mode

And check if works as expected...

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

{"name":"velociraptor","weight":50,"diet":"carnivorous"}
Enter fullscreen mode Exit fullscreen mode

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>>>
}

Enter fullscreen mode Exit fullscreen mode

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)
     })
Enter fullscreen mode Exit fullscreen mode

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)
        })
Enter fullscreen mode Exit fullscreen mode
$ 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"}]
Enter fullscreen mode Exit fullscreen mode

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)
        })
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
        });

Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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(())
}

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (4)

Collapse
wabiledev profile image
wabiledev

Hi,

thank you for tide based CRUD example. I have followed from the beginning and I am making progress.

I am struggling with the section "Nice! now let's try getting and individual dino...", the code does not compile with an error description:
Entry::Vacant(_entry) => Response::new(404),
| ^^^^^^^^^^^^^ not a tuple struct or tuple variant

I am not certain what to do to fix the error, though I recognise "Entry::C=Vacant(_entry)" as either a Rust enum or tuple construct.

Is there something I have missed?

Collapse
pepoviola profile image
Javier Viola Author

Hi wabiledev,
Thanks for reading this serie 😃. That part of the serie is before of the refactor and you can check in this tag (github.com/pepoviola/tide-basic-cr...).
I'm checking now and compiles and works as expected, but with an older version of tide (0.13) are you using the last one?

Thanks again for reading 🙌😃

Collapse
wabiledev profile image
wabiledev

Hi Javier,

I am using rust version 1.51 and Tide version 0.16.

I eventually managed to compile the code after importing the line "use std::collections::hash_map::Entry;".

Thank you

Thread Thread
pepoviola profile image
Javier Viola Author

Hi wabiledev,
Thanks! Yes, you need that import before use the Entry api. I will add a note into the post :) . Again thanks for reading this serie and help me to improve it.

Thanks!