DEV Community

Jordan Gregory
Jordan Gregory

Posted on

Writing a Rust CLI to complete the DigitalOcean Functions Challenge

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:

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

  2. 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" }
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

Discussion (0)