31
Let's create a basic crud api with Rust using Tide
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.$ 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
First let's create a route for create a
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 worksapp.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 fileuse 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
First decouple our
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 generalCargo 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!
31