blog: add How I Start: Rust (#126)
* blog: add How I Start: Rust * only one cargo test * fixes suggested by @dontlaugh
This commit is contained in:
parent
80b0db8abf
commit
a6f1fa3b95
|
@ -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
|
||||
<press control-c>
|
||||
```
|
||||
|
||||
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<HostInfo> {
|
||||
// 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<HostInfo> {
|
||||
// ...
|
||||
```
|
||||
|
||||
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<Json<HostInfo>> {
|
||||
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.
|
Loading…
Reference in New Issue