Giving Your JSON a Web API with Rust

aero

Reece McMillin

Posted on October 1, 2021

Giving Your JSON a Web API with Rust

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 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

{
    "1": "don't do bad things!",
    "2": "try to do good things!"
}
Enter fullscreen mode Exit fullscreen mode

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. 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 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:

#[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])
}
Enter fullscreen mode Exit fullscreen mode

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.

#[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
    }
}
Enter fullscreen mode Exit fullscreen mode

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![]?

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

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

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])
}
Enter fullscreen mode Exit fullscreen mode

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

[dependencies]
rocket = "0.5.0-rc.1"
serde_json = "1.0.68"
Enter fullscreen mode Exit fullscreen mode

./Rocket.toml

[default]
address = "0.0.0.0"
port = 8080
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!

💖 💪 🙅 🚩
aero
Reece McMillin

Posted on October 1, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related