20
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 :
Zero to Production in Rust by Luca Palmieri (@algo_luca)
Witter, a twitter clone in Rust by David Pedersen (@davidpdrsn )
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
andcargo
.
$ 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
file or install cargo-edit and use the
Cargo.tomladd
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 req
uest 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