Reece McMillin
Posted on October 1, 2021
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!"
}
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])
}
Let's walk through what's actually happening here.
- 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. - We open the file
law.json
and useserde_json
to convert it into aValue
in memory, which can be thought of as some hybrid of JSON value/Rust HashMap with the communicative power of Serde built in. - We use
rocket::build()
to do the heavy lifting.-
manage()
loads theValue
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![]
, likecatchers![]
, 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
}
}
There's some unfamiliar syntax here, but it's not too bad! Let's work through what's happening:
- We use another procedural macro to match the pattern
/<key>
, which just tells our function to expect someargument
in the form oflocalhost:8080/(argument)
and call itkey
. Notice that this is one of our function arguments! - Our second function argument is called
json
, which is expected to be a&State<Value>
. This is a reference (&
) to aState
wrapper around the JSONValue
from earlier. Remember thatmanage(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 applicationState
. -
if let
can be a little confusing.- We're essentially asking Rust to try evaluating
json.get(key)
, which could either be aSome(value)
or aNone
. - If
json
has the key we're asking for,get()
will wrap it in aSome()
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 expressionSome(String::from(value.as_str()...))
- in other words, just the value associated with that key.
- If we can unwrap the value, we'll place it in the
- Otherwise, it'll give us a
None
and we can trigger theelse
clause, returningNone
for the route handler and therefore triggering whatever we've set up as a 404 catcher.
- We're essentially asking Rust to try evaluating
Speaking of our 404 catcher, remember catchers![]
?
#[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
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
[dependencies]
rocket = "0.5.0-rc.1"
serde_json = "1.0.68"
./Rocket.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!
Posted on October 1, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.