Learning Rust with Actix, WASM & Giphy
Let's learn a little bit about Rust with a demo WebAssembly (WASM) application that allows a user to search for and save animated GIFs to a user profile using the GIPHY API.
The API is structured as a very simple JSON RPC API built using actix.rs. The client & server use the same exact data models (the same library code) for communicating over the network. All interaction is protected by JWT authN/authZ.
The client is a WASM application built using Rust & the Seed framework.
We are using Postgres for data storage & launchbadge/sqlx for the interface.
Check out the repository at github.com/thedodd/giphy-api.
Learning Objectives
First and foremost, let's learn something new about Rust!
- Review some nice language features. Let's just start off with a quick sampling of various language features which I love! There are a few chapters dedicated to these features.
- Build the app! We have a working application to study, so let's build it. This will give us some practice with the Rust toolchain.
- Bonus: Let's dive into Ownership, Borrowing & Lifetimes, the "hard parts" of Rust.
Nice Features: Option
Instead of nil
, None
, or Null
, in Rust we have the Option
enum type.
#![allow(unused_variables)] fn main() { enum Option<T> { Some(T), None, } }
An example of how we are using an Option
type in our app code.
#![allow(unused_variables)] fn main() { /// A GIF from the Giphy API which has been saved by a user. struct SavedGif { /// Object ID. pub id: i64, /// The ID of the user which has saved this GIF. pub user: i64, /// The ID of this GIF in the Giphy system. pub giphy_id: String, /// The title of the GIF. pub title: String, /// The URL of the GIF. pub url: String, /// The category given to this GIF by the user. pub category: Option<String>, } }
How might we use this?
#![allow(unused_variables)] fn main() { // Take the inner value, or a default. gif.category.unwrap_or_default(); // Take the inner value, or an explicit alternative. gif.category.unwrap_or(String::from("Woo!")); gif.category.unwrap_or_else(|| String::from("Woo!")); // Using a closure. // Match on the structure of the option itself. // This matches against the possible variants of the type. match gif.category { Some(val) => val, None => String::from("New Val"), } // If we just want to check for Some(..) or None. if let Some(val) = gif.category { // Use the inner value here. } if let None = gif.category { // No category, so do something else. } }
Why is this great? No more nil pointer dereferencing.
What about our own custom enum types? Here is one that we use heavily in the app.
#![allow(unused_variables)] fn main() { /// An API response. enum Response<D> { /// A success payload with data. Data(D), /// An error payload with an error. Error(Error), } }
Nice Features: Result
Instead of except Exception as ex
or if err != nil
or rescue ExceptionType
or try .. catch
or (the worst) if ret < 0
, in Rust we have another enum type: Result
.
#![allow(unused_variables)] fn main() { /// An enum, generic over a success type and an error type. enum Result<T, E> { Ok(T), Err(E), } }
Ok, so how do we use a result? Here is an example from our app (slightly abridged).
#![allow(unused_variables)] fn main() { /// Our error struct. struct Error {/* ... */} fn from_jwt(/* ... */) -> Result<Claims, Error> // ... snip ... let claims = // Decode a token into our `Claims` structure. claims.must_not_be_expired()?; // ... snip ... } }
That claims.must_not_be_expired()
call returns a Result
. If an error comes up, Rust has a dedicated syntax element — the postfix ?
operator — to perform an "early return" when a Result::Err(..)
is encountered.
What's more? Rust performs automatic type coercion with the ?
operator. What does this mean?
#![allow(unused_variables)] fn main() { // The std From trait. trait From<T> { fn from(T) -> Self; } }
How is this used?
#![allow(unused_variables)] fn main() { // Let's say `must_not_be_expired()` returns a different error type: fn must_not_be_expired(&self) -> Result<(), ExpirationError> {/*...*/} impl From<ExpirationError> for Error { fn from(src: ExpirationError) -> Error { Error{ // Use the values from src here. } } } }
So, when we suffix claims.must_not_be_expired()
with a ?
, in this context Rust will automatically use the From
impl we have above to convert the type for us.
Using Rust's match
syntax for structural matching also works as expected with results.
#![allow(unused_variables)] fn main() { match my_result { Ok(data) => data, Err(err) => { // Trace the error ... log the error ... transform the error ... whatever. tracing::error!("{}", err); return Err(err); } } }
Lots of methods are available on the Result
type as well.
#![allow(unused_variables)] fn main() { Result::Ok(0) .map(|val| val + 1) // Let's change the error type if one is encountered. .map_err(|_err| "something bad happened!") .and_then(|val| { if val > 0 { Ok(val) } else { Err("not good!!!") } }) }
The Rust standard syntax prelude includes the discriminants of the Result type for direct use, as seen above. So you can directly use Ok(..)
& Err(..)
in your code as a shorthand. Rust will infer the appropriate types based on function signatures and the like.
Nice Features: Traits, Generics & MetaProgramming
Traits
Some languages have Interfaces, some languages have Protocols, Rust was trying really hard to be the cool kid, so it has Traits instead.
#![allow(unused_variables)] fn main() { trait Worker { fn do_work(&self) -> Result<(), Error>; } struct ThreadedWorker; impl Worker for ThreadedWorker { fn do_work(&self) -> Result<(), Error> { Err(Error::default()) // FAIL! } } }
Trait inheritence is also supported. trait Worker: Awesome + Cool + Nifty {..}
requires that any implementor of the Worker
trait must also implement Awesome
, Cool
, and Nifty
.
Generics
The enums we've studied so far (Option & Result) are both generic types. Here is a generic type of our own which we use in this app.
#![allow(unused_variables)] fn main() { /// An API response. enum Response<D> { /// A success payload with data. Data(D), /// An error payload with an error. Error(Error), } }
Our response struct carries a data payload within its Data
variant, and an error in its Error
variant. At this point, we can use any type for D
.
Often times we don't actually want to allow any lowsy old type to be used though. How do constraint which types can be used?
#![allow(unused_variables)] fn main() { // We can constrain inline. fn ddos_attack<T: HttpEndpoint>(target: T) {..} // Or we can constrain with a `where` clause, // which is nice when you have lots of constraints. fn ddos_attack<T, C>(target: T, ctx: C) where T: HttpEndpoint, C: Context + Send + Sync, {..} }
MetaProgramming
There are a few kinds in Rust, here is one you will use ALL THE TIME.
#![allow(unused_variables)] fn main() { /// An API response. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(tag="result", content="payload")] enum Response<D> { /// A success payload with data. #[serde(rename="data")] Data(D), /// An error payload with an error. #[serde(rename="error")] Error(Error), } }
In this case, the #[derive(Serialize)]
attribute (focusing only on Serialize
for now), invokes a function at compile-type which will run Rust code over the AST of this enum and generate more code. In this case, in generates code to allow this enum to be serialized into various data formats (JSON, YAML, &c). These are called "procedural macros".
The #[serde(..)]
attribute is a "helper attribute" of the Serialize & Deserialize macros defined in the serde library code itself, and modifies the macro's behavior.
Build the App
First, you'll need Rust. Head on over to rustup.rs and follow the instructions there to setup the Rust toolchain. After that, let's also add the needed compiler target for the WASM instruction set:
# Add the WASM 32-bit instruction set as a compilation target.
rustup target add wasm32-unknown-unknown
# While we're at it, let's install the wasm-bindgen-cli
# which we will need for our WASM builds.
cargo install wasm-bindgen-cli --version=0.2.55
Second, you'll need to have docker in place to run the Postgres database, check out the docker installation docs if you don't already have docker on your machine.
Now that you have all of the tools in place, let's bring up the DB and build our Rust code.
# Boot Postgres. This will also initialize our tables.
docker run -d --name postgres \
-e POSTGRES_PASSWORD=pgpass -p 54321:5432 \
-v `pwd`/pg.sql:/docker-entrypoint-initdb.d/pg.sql \
postgres
# Build the UI.
cargo build -p client --release --target wasm32-unknown-unknown
# Run wasm-bindgen on our output WASM.
wasm-bindgen target/wasm32-unknown-unknown/release/client.wasm --no-modules --out-dir ./static
# Now, we run our API which will also serve our WASM bundle, HTML and other assets.
source .env # Needed env vars.
cargo run -p server --release
Now you're ready to start using the app. Simply navigate to localhost:9000 to get started.
Bonus: Ownership, Borrowing & Lifetimes
Data Ownership
Data is always owned. References are a way to lease out access to the owned data, and lifetimes help you (and the compiler) to keep track of this lease.
#![allow(unused_variables)] fn main() { /// The application state object. #[derive(Clone)] pub struct State { pub db: PgPool, pub client: Client, pub config: Arc<Config>, } }
Let's have a look at our API code and how we lease out access to our config data (see server/src/api.rs
).
Embedding a Lifetime
Remember that with references, you are dealing with data that is owned by something else.
struct DBInfo<'a> { name: &'a str, tables: u64, } fn build_info<'a>(name: &'a str, db: &mut PgConn) -> DBInfo<'a> { // Do some work, get some info. let tables = get_table_count(db); DBInfo{name, tables} } fn main() { let my_db_name = String::from("oxidize"); // Build our info struct. let info = build_info(&my_db_name, get_db()); // Report our info. metrics.report_info(info); // <-- the lifetime 'a is still alive here. // ... do more cool stuff. }
Why is this significant?
- Your code doesn't have to check to see if
info.name
is nil/null/void ... because that doesn't exist in Rust. - For as long as
'a
is alive and well, that reference tomy_db_name
stands. Can not be mutated. Can not be destroyed. - No garbage collector needed.
Remember, lifetime rules apply to &
references. Not to the various pointer types in Rust (Box, Rc, Arc etc), though you could still pass around references to them if needed.
Mutability & Exclusive References
In Rust, we have references &
(shared) and we have mutable references &mut
(exclusive).
This, combined with Rust's lifetime system, ensures that we don't have pointers retained in random parts of our app which might be making subtle changes to values behind the scenes.
Let's look at client/src/state.rs
for some examples on handling mutability.