From a6f1fa3b9571c715500a9bcf66e70ed00d398525 Mon Sep 17 00:00:00 2001 From: Christine Dodrill Date: Sun, 15 Mar 2020 17:50:44 -0400 Subject: [PATCH] blog: add How I Start: Rust (#126) * blog: add How I Start: Rust * only one cargo test * fixes suggested by @dontlaugh --- blog/how-i-start-rust-2020-03-15.markdown | 558 ++++++++++++++++++++++ 1 file changed, 558 insertions(+) create mode 100644 blog/how-i-start-rust-2020-03-15.markdown diff --git a/blog/how-i-start-rust-2020-03-15.markdown b/blog/how-i-start-rust-2020-03-15.markdown new file mode 100644 index 0000000..67385da --- /dev/null +++ b/blog/how-i-start-rust-2020-03-15.markdown @@ -0,0 +1,558 @@ +--- +title: "How I Start: Rust" +date: 2020-03-15 +series: howto +tags: + - rust + - how-i-start + - nix +--- + +# How I Start: Rust + +[Rust][rustlang] is an exciting new programming language that makes it easy to +make understandable and reliable software. It is made by Mozilla and is used by +Amazon, Google, Microsoft and many other large companies. + +[rustlang]: https://www.rust-lang.org/ + +Rust has a reputation of being difficult because it makes no effort to hide what +is going on. I'd like to show you how I start with Rust projects. Let's make a +small HTTP service using [Rocket][rocket]. + +[rocket]: https://rocket.rs + +- Setting up your environment +- A new project +- Testing +- Adding functionality +- OpenAPI specifications +- Error responses +- Shipping it in a docker image + +## Setting up your environment + +The first step is to install the Rust compiler. You can use any method you like, +but since we are requiring the nightly version of Rust for this project, I +suggest using [rustup][rustup]: + +[rustup]: https://rustup.rs/ + +```console +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- --default-toolchain nightly +``` + +If you are using [NixOS][nixos] or another Linux distribution with [Nix][nix] +installed, see [this post][howistartnix] for some information on how to set up +the Rust compiler. + +[nixos]: https://nixos.org/nixos/ +[nix]: https://nixos.org/nix/ +[howistartnix]: https://christine.website/blog/how-i-start-nix-2020-03-08 + +## A new project + +[Rocket][rocket] is a popular web framework for Rust programs. Let's use that to +create a small "hello, world" server. We will need to do the following: + +[rocket]: https://rocket.rs/ + +- Create the new Rust project +- Add Rocket as a dependency +- Write the hello world route +- Test a build of the service with `cargo build` +- Run it and see what happens + +### Create the new Rust project + +Create the new Rust project with `cargo init`: + +```console +$ cargo init --vcs git . + Created binary (application) package +``` + +This will create the directory `src` and a file named `Cargo.toml`. Rust code +goes in `src` and the `Cargo.toml` file configures dependencies. Adding the +`--vcs git` flag also has cargo create a [gitignore][gitignore] file so that the +target folder isn't tracked by git. + +[gitignore]: https://git-scm.com/docs/gitignore + +### Add Rocket as a dependency + +Open `Cargo.toml` and add the following to it: + +```toml +[dependencies] +rocket = "0.4.4" +``` + +Then download/build [Rocket][rocket] with `cargo build`: + +```console +$ cargo build +``` + +This will download all of the dependencies you need and precompile Rocket, and it +will help speed up later builds. + +### Write our "hello world" route + +Now put the following in `src/main.rs`: + +```rust +#![feature(proc_macro_hygiene, decl_macro)] // Nightly-only language features needed by Rocket + +// Import the rocket macros +#[macro_use] +extern crate rocket; + +/// Create route / that returns "Hello, world!" +#[get("/")] +fn index() -> &'static str { + "Hello, world!" +} + +fn main() { + rocket::ignite().mount("/", routes![index]).launch(); +} +``` + +### Test a build + +Rerun `cargo build`: + +```console +$ cargo build +``` + +This will create the binary at `target/debug/helloworld`. Let's run it locally +and see if it works: + +```console +$ ./target/debug/helloworld +``` + +And in another terminal window: + +```console +$ curl http://127.0.0.1:8000 +Hello, world! +$ fg + +``` + +The HTTP service works. We have a binary that is created with the Rust compiler. +This binary will be available at `./target/debug/helloworld`. However, it could +use some tests. + +## Testing + +Rocket has support for [unit testing][rockettest] built in. Let's create a tests +module and verify this route in testing. + +[rockettest]: https://rocket.rs/v0.4/guide/testing/ + +### Create a tests module + +Rust allows you to nest modules within files using the `mod` keyword. Create a +`tests` module that will only build when testing is requested: + +[rustmod]: https://doc.rust-lang.org/rust-by-example/mod/visibility.html + +```rust +#[cfg(test)] // Only compile this when unit testing is requested +mod tests { + use super::*; // Modules are their own scope, so you + // need to explictly use the stuff in + // the parent module. + + use rocket::http::Status; + use rocket::local::*; + + #[test] + fn test_index() { + // create the rocket instance to test + let rkt = rocket::ignite().mount("/", routes![index]); + + // create a HTTP client bound to this rocket instance + let client = Client::new(rkt).expect("valid rocket"); + + // get a HTTP response + let mut response = client.get("/").dispatch(); + + // Ensure it returns HTTP 200 + assert_eq!(response.status(), Status::Ok); + + // Ensure the body is what we expect it to be + assert_eq!(response.body_string(), Some("Hello, world!".into())); + } +} +``` + +### Run tests + +`cargo test` is used to run tests in Rust. Let's run it: + +```console +$ cargo test + Compiling helloworld v0.1.0 (/home/cadey/code/helloworld) + Finished test [unoptimized + debuginfo] target(s) in 1.80s + Running target/debug/deps/helloworld-49d1bd4d4f816617 + +running 1 test +test tests::test_index ... ok +``` + +## Adding functionality + +Most HTTP services return [JSON][json] or JavaScript Object Notation as a way to +pass objects between computer programs. Let's use Rocket's [JSON +support][rocketjson] to add a `/hostinfo` route to this app that returns some +simple information: + +[json]: https://www.json.org/json-en.html +[rocketjson]: https://api.rocket.rs/v0.4/rocket_contrib/json/index.html + +- the hostname of the computer serving the response +- the process ID of the HTTP service +- the uptime of the system in seconds + +### Encoding things to JSON + +For encoding things to JSON, we will be using [serde][serde]. We will need to +add serde as a dependency. Open `Cargo.toml` and put the following lines in it: + +[serde]: https://serde.rs/ + +```toml +[dependencies] +serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } +``` + +This lets us use `#[derive(Serialize, Deserialize)]` on our Rust structs, which +will allow us to automate away the JSON generation code _at compile time_. For +more information about derivation in Rust, see [here][rustderive]. + +[rustderive]: https://doc.rust-lang.org/rust-by-example/trait/derive.html + +Let's define the data we will send back to the client using a [struct][ruststruct]. + +[ruststruct]: https://doc.rust-lang.org/rust-by-example/custom_types/structs.html + +```rust +use serde::*; + +/// Host information structure returned at /hostinfo +#[derive(Serialize, Debug)] +struct HostInfo { + hostname: String, + pid: u32, + uptime: u64, +} +``` + +To implement this call, we will need another few dependencies in the `Cargo.toml` +file. We will use [gethostname][gethostname] to get the hostname of the machine +and [psutil][psutil] to get the uptime of the machine. Put the following below +the `serde` dependency line: + +[gethostname]: https://crates.io/crates/gethostname +[psutil]: https://crates.io/crates/psutil + +```toml +gethostname = "0.2.1" +psutil = "3.0.1" +``` + +Finally, we will need to enable Rocket's JSON support. Put the following at the +end of your `Cargo.toml` file: + +```toml +[dependencies.rocket_contrib] +version = "0.4.4" +default-features = false +features = ["json"] +``` + +Now we can implement the `/hostinfo` route: + +```rust +/// Create route /hostinfo that returns information about the host serving this +/// page. +#[get("/hostinfo")] +fn hostinfo() -> Json { + // gets the current machine hostname or "unknown" if the hostname doesn't + // parse into UTF-8 (very unlikely) + let hostname = gethostname::gethostname() + .into_string() + .or(|_| "unknown".to_string()) + .unwrap(); + + Json(HostInfo{ + hostname: hostname, + pid: std::process::id(), + uptime: psutil::host::uptime() + .unwrap() // normally this is a bad idea, but this code is + // very unlikely to fail. + .as_secs(), + }) +} +``` + +And then register it in the main function: + +```rust +fn main() { + rocket::ignite() + .mount("/", routes![index, hostinfo]) + .launch(); +} +``` + +Now rebuild the project and run the server: + +```console +$ cargo build +$ ./target/debug/helloworld +``` + +And in another terminal test it with `curl`: + +```console +$ curl http://127.0.0.1:8000 +{"hostname":"shachi","pid":4291,"uptime":13641} +``` + +You can use a similar process for any kind of other route. + +## OpenAPI specifications + +[OpenAPI][openapi] is a common specification format for describing API routes. +This allows users of the API to automatically generate valid clients for them. +Writing these by hand can be tedious, so let's pass that work off to the +compiler using [okapi][okapi]. + +[openapi]: https://swagger.io/docs/specification/about/ +[okapi]: https://github.com/GREsau/okapi + +Add the following line to your `Cargo.toml` file in the `[dependencies]` block: + +```toml +rocket_okapi = "0.3.6" +schemars = "0.6" +okapi = { version = "0.3", features = ["derive_json_schema"] } +``` + +This will allow us to generate OpenAPI specifications from Rocket routes and the +types in them. Let's import the rocket_okapi macros and use them: + +```rust +// Import OpenAPI macros +#[macro_use] +extern crate rocket_okapi; + +use rocket_okapi::JsonSchema; +``` + +We need to add JSON schema generation abilities to `HostInfo`. Change: + +```rust +#[derive(Serialize, Debug)] +``` + +to + +```rust +#[derive(Serialize, JsonSchema, Debug)] +``` + +to generate the OpenAPI code for our type. + +Next we can add the `/hostinfo` route to the OpenAPI schema: + +```rust +/// Create route /hostinfo that returns information about the host serving this +/// page. +#[openapi] +#[get("/hostinfo")] +fn hostinfo() -> Json { + // ... +``` + +Also add the index route to the OpenAPI schema: + +```rust +/// Create route / that returns "Hello, world!" +#[openapi] +#[get("/")] +fn index() -> &'static str { + "Hello, world!" +} +``` + +And finally update the main function to use openapi: + +```rust +fn main() { + rocket::ignite() + .mount("/", routes_with_openapi![index, hostinfo]) + .launch(); +} +``` + +Then rebuild it and run the server: + +```console +$ cargo build +$ ./target/debug/helloworld +``` + +And then in another terminal: + +```console +$ curl http://127.0.0.1:8000/openapi.json +``` + +This should return a large JSON object that describes all of the HTTP routes and +the data they return. To see this visually, change main to this: + +```rust +use rocket_okapi::swagger_ui::{make_swagger_ui, SwaggerUIConfig}; + +fn main() { + rocket::ignite() + .mount("/", routes_with_openapi![index, hostinfo]) + .mount( + "/swagger-ui/", + make_swagger_ui(&SwaggerUIConfig { + url: Some("../openapi.json".to_owned()), + urls: None, + }), + ) + .launch(); +} +``` + +Then rebuild and run the service: + +```console +$ cargo build +$ ./target/debug/helloworld +``` + +And [open the swagger UI](http://127.0.0.1:8000/swagger-ui/) in your favorite +browser. This will show you a graphical display of all of the routes and the +data types in your service. For an example, see +[here](https://printerfacts.cetacean.club/swagger-ui/index.html). + +## Error responses + +Earlier in the /hostinfo route we glossed over error handling. Let's correct +this using the [okapi error type][okapierror]. Let's use the +[OpenAPIError][okapierror] type in the helloworld function: + +[okapierror]: https://docs.rs/rocket_okapi/0.3.6/rocket_okapi/struct.OpenApiError.html + +```rust +/// Create route /hostinfo that returns information about the host serving +/// this page. +#[openapi] +#[get("/hostinfo")] +fn hostinfo() -> Result> { + match gethostname::gethostname().into_string() { + Ok(hostname) => Ok(Json(HostInfo { + hostname: hostname, + pid: std::process::id(), + uptime: psutil::host::uptime().unwrap().as_secs(), + })), + Err(_) => Err(OpenApiError::new(format!( + "hostname does not parse as UTF-8" + ))), + } +} +``` + +When the `into_string` operation fails (because the hostname is somehow invalid +UTF-8), this will result in a non-200 response with the `"hostname does not parse +as UTF-8"` message. + +## Shipping it in a docker image + +Many deployment systems use [Docker][docker] to describe a program's environment +and dependencies. Create a `Dockerfile` with the following contents: + +```Dockerfile +# Use the minimal image +FROM rustlang/rust:nightly-slim AS build + +# Where we will build the program +WORKDIR /src/helloworld + +# Copy source code into the container +COPY . . + +# Build the program in release mode +RUN cargo build --release + +# Create the runtime image +FROM ubuntu:18.04 + +# Copy the compiled service binary +COPY --from=build /src/helloworld/target/release/helloworld /usr/local/bin/helloworld + +# Start the helloworld service on container boot +CMD ["usr/local/bin/helloworld"] +``` + +And then build it: + +```console +$ docker build -t xena/helloworld . +``` + +And then run it: + +```console +$ docker run --rm -itp 8000:8000 xena/helloworld +``` + +And in another terminal: + +```console +$ curl http://127.0.0.1:8000 +Hello, world! +``` + +From here you can do whatever you want with this service. You can deploy it to +Kubernetes with a manifest that would look something like [this][k8shack]. + +[k8shack]: https://clbin.com/zSPDs + +--- + +This is how I start a new Rust project. I put all of the code described in this +post in [this GitHub repo][helloworldrepo] in case it helps. Have fun and be +well. + +[helloworldrepo]: https://github.com/Xe/helloworld + +--- + +For some "extra credit" tasks, try and see if you can do the following: + +- Customize the environment of the container by following the [Rocket + configuration documentation](https://rocket.rs/v0.4/guide/configuration/) and + docker [environment variables][dockerenvvars] +- Use Rocket's [templates][rockettemplate] to make the host information show up + in HTML +- Add tests for the `/hostinfo` route +- Make a route that always returns errors, what does it look like? + +[dockerenvvars]: https://docs.docker.com/engine/reference/builder/#env +[rockettemplate]: https://api.rocket.rs/v0.4/rocket_contrib/templates/index.html + +Many thanks to [Coleman McFarland](https://coleman.codes/) for proofreading this +post.