Writing a Rust CLI to complete the DigitalOcean Functions Challenge
Jordan Gregory
Posted on June 15, 2022
Prelude
To continue from my Go CLI write-up and my Python CLI Write-up, I will go in to detail about writing a Rust CLI to perform the same DigitalOcean Functions Challenge
The Code
I'm sensing a theme here, but much like my last two write-ups, I simply threw everything into the main.rs
file that was created with the cargo new sharks
command.
First things first, if you have done any development with Rust that accesses the web via JSON, there are a few essential crates to grab:
Reqwest is a crate that makes sending web requests simple, and even though I used the blocking client, you can use Tokio to make Reqwest asynchronous just as simply.
Serde / serde_json / serde_derive are obvious choices for serialization/deserialization.
For the CLI, I personally like to use Structopt, but feel free to use whatever you are comfortable with.
My Cargo.toml
dependencies look like so:
[dependencies]
structopt = { version = "0.3" }
reqwest = { version = "0.11", features = ["blocking", "json"]}
serde = { version = "1.0" }
serde_json = { version = "1.0" }
serde_derive = { version = "1.0" }
Now, knowing that I want something similar to my previous two write-ups, I started with the API URL constant and some Request and Response structs:
// src/main.rs
use std::collections::HashMap;
use serde_derive::{Serialize,Deserialize};
const API_URL: &str = "https://functionschallenge.digitalocean.com/api/sammy";
// Obviously, the serialize trait needs to be here so that
// we can turn this into JSON later. I usually use debug here
// as well just in case I need to do a little println!
// debugging.
#[derive(Debug, Serialize)]
struct Request {
name: String,
// Because of the type keyword, we needed to name this
// field something other than type, so _type suits me
// just fine, but we have to tell serde that we want to
// rename this field when we go to actually serialize
// the JSON.
#[serde(rename(serialize = "type"))]
_type: String,
}
// Again, and obvious deserialize trait needed with the
// response. And again, the almost obligatory Debug trait.
#[derive(Debug, Deserialize)]
struct Response {
message: String,
// In this case, we need to tell serde to give us the
// "zero" value of a HashMap<String,Vec<String>> because
// a successful response will not have this field, and
// will cause the app to panic because this field doesn't
// get used.
#[serde(default)]
errors: HashMap<String, Vec<String>>
}
And for the request/response, this is really all we need to do.
Now, I'll set up the CLI struct really fast:
// src/main.rs
...
use structopt::StructOpt;
...
// I went ahead and created a "possible values" constant here
// so that we have the CLI filter our sammy type input for us.
const TYPE_VALUES: &[&str] = &[
"sammy",
"punk",
"dinosaur",
"retro",
"pizza",
"robot",
"pony",
"bootcamp",
"xray"
];
...
// In classic fashion, we have to derive the structopt trait
// as well as the obligatory debug trait. The additional
// structopt config follows it.
#[derive(Debug, StructOpt)]
#[structopt(name = "sharks", version = "0.1.0")]
struct Opt {
#[structopt(long = "name")]
sammy_name: String,
#[structopt(long = "type", possible_values(TYPE_VAULES))]
sammy_type: String,
}
Now all we have to do is pull it all together in the main()
function:
// src/main.rs
...
fn main() {
// Build the opt struct from the arguments provided
let opt: Opt = Opt::from_args();
// Build a new request from those arguments
let r: Request = Request {
// notice the clones here, I needed that values later
// in the output, so this was just the most painless
// way to not fight the borrow checker :D
name: opt.sammy_name.clone(),
_type: opt.sammy_type.clone(),
};
// Grab a new blocking client. I don't care if this
// blocks because of what this app is and does, but
// you may care more. Feel free to try this async.
let c = reqwest::blocking::Client::new();
// Build the JSON from our Request struct
let resp_body = serde_json::to_string(&r).unwrap();
// Send the request
let resp: Response = c.post(API_URL)
.header("ACCEPT", "application/json") // Set a header
.header("CONTENT-TYPE", "application/json") // Set a header
.body(resp_body) // Set the body
.send() // Send it
.expect("failed to get response") // Don't fail
.json() // Convert response to JSON
.expect("failed to get payload"); // Don't fail
// Here, I'm just doing a little error checking to see if
// something exists. There are obviously far nicer ways
// to do this, but again, this was fast and accomplished
// the goal.
if resp.errors.len() > 0 {
println!("ERROR: {:#?}", resp.errors);
return
}
// Give us a success message if it worked!
println!("Successfully created Sammy: {} of type {}", opt.sammy_name, opt.sammy_type)
}
With all this written down, now we can just do a cargo run -- --name <your sammy name> --type <your sammy type>
and it works as expected.
Conclusion
Like usual, if you want to see the full file, feel free to check it out on the repository here:
https://github.com/j4ng5y/digitalocean-functions-challenge/tree/main/rust
Posted on June 15, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.