Giving Your JSON a Web API with Rust
Tags: rust, webdev, api, rocket
Published: 2021-10-01 20:03:55

## The Problem
I'm currently working on a civic tech project where there's a simple but important need for small chunks of unchanging text (legal statutes) to be accessible through an API. These are currently stored in a [large blob of formatted text](https://www.ksrevisor.org/statutes/chapters/ch21/021_066_0014.html) that would be expensive and complicated to parse on every request! This is a great use-case for a human-readable, machine-parsable format like JSON alongside a bare-bones web API.

## The Data
It takes a tiny bit of manual effort to translate the page into something that makes sense, but once it's set up you've got your pick of tech-stack - just about everything can read JSON. Let's take a first look at our statute API data:
### `./law.json`
```json
{
    "1": "don't do bad things!",
    "2": "try to do good things!"
}
```

## The Technology
I'll be the first to admit that I'm a little oversold on Rust. The compiler is smart enough to remove an enormous amount of cognitive overhead, the type system makes domain modelling easy, and the community is super sweet to newcomers. There's a bit of an on-ramp, but the educational materials are great and small projects are generally pretty legible for non-Rust programmers.

I'm not much of a web guy, but I've had great luck with one of Rust's web frameworks, [Rocket](https://www.rocket.rs/). It's dead simple to set up and removes an enormous amount of configuration responsibility. This isn't perfect for every project, but it is for us!

[Serde](https://serde.rs/) is the end-all-be-all serializer/deserializer for Rust. This is how we'll read our JSON to a value.

## Rust
Rocket is largely macro-based - we can offload the cognitive overhead and let Rocket's procedural macros generate the bulk of the code for us. Let's look at our API's entry point:
```rust
#[launch]
fn rocket() -> _ {
    let rdr = File::open("law.json").expect("Failed to open `law.json`.");
    let json: Value = serde_json::from_reader(rdr).expect("Failed to convert `rdr` into serde_json::Value.");

    rocket::build()
        .manage(json)
        .register("/", catchers![not_found])
        .mount("/", routes![index])
}
```

Let's walk through what's actually happening here.

1. We define our entry point `rocket()` with the `#[launch]` macro. Rocket uses `#[launch]` to create the main function, start the server, and begin printing out useful diagnostic information.
2. We open the file `law.json` and use `serde_json` to convert it into a `Value` in memory, which can be thought of as some hybrid of JSON value/Rust HashMap with the communicative power of Serde built in.
3. We use `rocket::build()` to do the heavy lifting.
   * `manage()` loads the `Value` from before into the application state.
   * `register()` sets up catchers which handle error conditions.
     * `catchers![]` is a macro that takes the names of our error-handling catcher functions.
   * `mount()` mounts routes to a certain path prefix.
     * `routes![]`, like `catchers![]`, is a macro that takes the names of our route-handler functions.

There's a lot going on there! So we know `not_found` should be the name of an error-handling function and `index` should be the name of a route-handling function. Let's take a look at each of these.

```rust
#[get("/<key>")]
fn index(key: &str, json: &State<Value>) -> Option<String> {
    if let Some(value) = json.get(key) {
        Some(String::from(value.as_str().expect("Failed to convert value to &str.")))
    } else {
        None
    }
}
```

There's some unfamiliar syntax here, but it's not too bad! Let's work through what's happening:

1. We use another procedural macro to match the pattern `/<key>`, which just tells our function to expect some `argument` in the form of `localhost:8080/(argument)` and call it `key`. Notice that this is one of our function arguments!
2. Our second function argument is called `json`, which is expected to be a `&State<Value>`. This is a reference (`&`) to a `State` wrapper around the JSON `Value` from earlier. Remember that `manage(json)` line? That allowed us to pass our JSON around between functions so we can reference the information as a part of our request handlers! In other words, it's a part of our application `State`.
3. `if let` can be a little confusing.
   * We're essentially asking Rust to try evaluating `json.get(key)`, which could either be a `Some(value)` or a `None`.
   * If `json` has the key we're asking for, `get()` will wrap it in a `Some()` value to indicate something's there for us to unwrap.
     * If we *can* unwrap the value, we'll place it in the `value` variable.
     * Within the `if let` block, it'll evaluate and return the expression `Some(String::from(value.as_str()...))` - in other words, just the value associated with that key.
   * Otherwise, it'll give us a `None` and we can trigger the `else` clause, returning `None` for the route handler and therefore triggering whatever we've set up as a 404 catcher.

Speaking of our 404 catcher, remember `catchers![]`?
```rust
#[catch(404)]
fn not_found() -> String {
    String::from("Not Found")
}
```

So `not_found()` uses another procedural macro, `#[catch(404)]`. This is why I love Rocket! How obvious is that? Whenever we need to `catch` a `404` (not found) error, spit out a string that says `Not Found`. Dead simple, just works, we're done. Great!

These are a lot of ideas in not a lot of code, but don't worry, we're at the end! Here's the full source file in all it's glory.
### `./src/main.rs`
```rust
use std::fs::File;
use rocket::State;
use serde_json::Value;
#[macro_use] extern crate rocket;

#[catch(404)]
fn not_found() -> String {
    String::from("Not Found")
}

#[get("/<key>")]
fn index(key: &str, json: &State<Value>) -> Option<String> {
    if let Some(value) = json.get(key) {
        Some(String::from(value.as_str().expect("Failed to convert value to &str.")))
    } else {
        None
    }
}

#[launch]
fn rocket() -> _ {
    let rdr = File::open("law.json").expect("Failed to open `law.json`.");
    let json: Value = serde_json::from_reader(rdr).expect("Failed to convert `rdr` into serde_json::Value.");

    rocket::build()
        .manage(json)
        .register("/", catchers![not_found])
        .mount("/", routes![index])
}
```
This project requires a tiny bit of configuration to set up - a couple lines added to your `Cargo.toml` plus a new file called `Rocket.toml` (with settings for a future Docker deployment).

### `./Cargo.toml`
```toml
[dependencies]
rocket = "0.5.0-rc.1"
serde_json = "1.0.68"
```
### `./Rocket.toml`
```toml
[default]
address = "0.0.0.0"
port = 8080
```

Now, let's look at it in action.

## Liftoff
In your source directory, run `cargo run`. If everything's gone well, you'll be greeted with a ton of diagnostic info ending with `🚀 Rocket has launched from http://0.0.0.0:8080`. Awesome! Let's try a query or two (our entire set of JSON keys).

```
$ curl localhost:8080/1
> don't do bad things!

$ curl localhost:8080/2
> try to do good things!
```

Perfect, the happy path is set and we're seeing exactly the values we planned for. What about something we don't expect?

```
$ curl localhost:8080/3
> Not Found
```

As soon as we request a route not defined in our JSON document, Rocket routes to the 404 catcher defined in `not_found`. Pretty slick, huh?

## Conclusion
Rust is known to be great at a lot of things, but ergonomics typically isn't cited as one of them. Rocket shows that that isn't necessarily the case - 30 easy-to-read lines and we've got a dynamic API serving up JSON queries. That said, there are plenty of opportunities to improve the flexibility of our API.
* Does editing your JSON file on disk update the API in realtime?
  * Does it need to?
  * What would be a reasonable caching method?
* Does our API handle nested JSON?
  * How would you implement nested paths in Rocket?
* Is a URL-based endpoint the only option?
  * How else could you imagine retrieving this information with a GET request?

This was a brief introduction based on a real-world use-case, but we only touched the very tip of the iceberg. Keep exploring!