Printing ASCII cats to the terminal

Now that our development environment is all set up, let's make something useful!

Creating the catscii crate

From a VS Code window connected to our VM (as we just set up), let's make a new Rust project:

amos@miles:~$ cargo new catscii
     Created binary (application) `catscii` package

And open it in a new VSCode window:

amos@miles:~$ code catscii

Even though we're running this "code" command in the VM guest, VSCode set up enough plumbing that it communicates back to the host, telling it to open another VSCode window, connected to the same SSH remote, in the right folder.

If you have the rust-analyzer extension enabled, like I said you should in the last part, VSCode will ask you if you trust the author of the files in this folder, because cargo build scripts can run arbitrary code (just like Makefiles, etc. — you may have noticed we haven't reviewed the tens of millions of lines of code involved in what we've done so far).

I trust myself, so I'll answer yes:

VSCode's "Do you trust the authors of the files in this folder?" prompt. Clicking Yes enables all features, clicking No switches to restricted mode

Opening src/main.rs in the Explorer (left) shows us the Rust code cargo generated for us. Hovering over symbols like println! shows us docs:

VSCode screenshot, as described in the previous paragraph

Clicking the "Run".. button? Ghost button? Inlay? Thingy on top of main launches our program in a new integrated terminal:

It says meow because I changed it from Hello World to meow. VSCode says the terminal will be re-used by other tasks and can be closed safely.

And if you don't see a Run button it's because you haven't installed the rust-analyzer extension yet, or it somehow went wrong and you'll want to fix that before proceeding.

Hitting the cat API

At the time of this writing, there is an API that returns a JSON payload with the URL of a cat picture:

amos@miles:~/catscii$ curl https://api.thecatapi.com/v1/images/search; echo
[{"id":"cda","url":"https://cdn2.thecatapi.com/images/cda.jpg","width":467,"height":700}]

Let's try and grab that from Rust.

Amos

This is the point where everyone usually goes "Wait, why do we need async Rust for this?", "What even is async Rust?", "Why doesn't it work more like flabaglorp?"

And the answer is shhhhhhhhhhhh, we're writing a web service, we'll want async anyway, I promise it won't be that bad, hold my hand, we'll be fine you and I.

So, have some koolaid, do not question the series, and let's add a dependency on the tokio runtime.

$ cargo add tokio@1 -features full
    Updating crates.io index
      Adding tokio v1.22.0 to dependencies.
             Features:
             + bytes
						(cut)

And change our main function to be async, with the tokio::main attribute macro:

#[tokio::main]
async fn main() {
    println!("Meow");
}
Cool bear

But Amos, what does this attribute do?

Amos

It generates a bunch of code you don't need to write yourself is what it does.

You can use rust-analyzer's "Expand macro recursively" functionality to see what it generates, because you definitely installed it by now, because you don't want to make me very sad and/or flaunt some unearned confidence now do you?

And now our program is meowing, still, but we can do asynchronous stuff in there:

Let's add a crate that lets us make HTTP requests: reqwest.

$ cargo add reqwest@0.11 --features json
    Updating crates.io index
      Adding reqwest v0.11 to dependencies.
             Features as of v0.11.0:
             + __tls
             + default-tls
             (cut)

To make sure everything went fine, let's run cargo check:

$ cargo check
   Compiling openssl-sys v0.9.78
error: failed to run custom build command for `openssl-sys v0.9.78`

Caused by:
  process didn't exit successfully: `/home/amos/catscii/target/debug/build/openssl-sys-8f8c0dbd6d813330/build-script-main` (exit status: 101)
  --- stdout
  cargo:rustc-cfg=const_fn
  cargo:rustc-cfg=openssl
  cargo:rerun-if-env-changed=X86_64_UNKNOWN_LINUX_GNU_OPENSSL_LIB_DIR
  X86_64_UNKNOWN_LINUX_GNU_OPENSSL_LIB_DIR unset
  cargo:rerun-if-env-changed=OPENSSL_LIB_DIR
  OPENSSL_LIB_DIR unset
  cargo:rerun-if-env-changed=X86_64_UNKNOWN_LINUX_GNU_OPENSSL_INCLUDE_DIR
  X86_64_UNKNOWN_LINUX_GNU_OPENSSL_INCLUDE_DIR unset
  cargo:rerun-if-env-changed=OPENSSL_INCLUDE_DIR
  OPENSSL_INCLUDE_DIR unset
  cargo:rerun-if-env-changed=X86_64_UNKNOWN_LINUX_GNU_OPENSSL_DIR
  X86_64_UNKNOWN_LINUX_GNU_OPENSSL_DIR unset
  cargo:rerun-if-env-changed=OPENSSL_DIR
  OPENSSL_DIR unset
  cargo:rerun-if-env-changed=OPENSSL_NO_PKG_CONFIG
  cargo:rerun-if-env-changed=PKG_CONFIG_x86_64-unknown-linux-gnu
  cargo:rerun-if-env-changed=PKG_CONFIG_x86_64_unknown_linux_gnu
  cargo:rerun-if-env-changed=HOST_PKG_CONFIG
  cargo:rerun-if-env-changed=PKG_CONFIG
  cargo:rerun-if-env-changed=OPENSSL_STATIC
  cargo:rerun-if-env-changed=OPENSSL_DYNAMIC
  cargo:rerun-if-env-changed=PKG_CONFIG_ALL_STATIC
  cargo:rerun-if-env-changed=PKG_CONFIG_ALL_DYNAMIC
  cargo:rerun-if-env-changed=PKG_CONFIG_PATH_x86_64-unknown-linux-gnu
  cargo:rerun-if-env-changed=PKG_CONFIG_PATH_x86_64_unknown_linux_gnu
  cargo:rerun-if-env-changed=HOST_PKG_CONFIG_PATH
  cargo:rerun-if-env-changed=PKG_CONFIG_PATH
  cargo:rerun-if-env-changed=PKG_CONFIG_LIBDIR_x86_64-unknown-linux-gnu
  cargo:rerun-if-env-changed=PKG_CONFIG_LIBDIR_x86_64_unknown_linux_gnu
  cargo:rerun-if-env-changed=HOST_PKG_CONFIG_LIBDIR
  cargo:rerun-if-env-changed=PKG_CONFIG_LIBDIR
  cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR_x86_64-unknown-linux-gnu
  cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR_x86_64_unknown_linux_gnu
  cargo:rerun-if-env-changed=HOST_PKG_CONFIG_SYSROOT_DIR
  cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR
  run pkg_config fail: "Could not run `\"pkg-config\" \"--libs\" \"--cflags\" \"openssl\"`\nThe pkg-config command could not be found.\n\nMost likely, you need to install a pkg-config package for your OS.\nTry `apt install pkg-config`, or `yum install pkg-config`,\nor `pkg install pkg-config`, or `apk add pkgconfig` depending on your distribution.\n\nIf you've already installed it, ensure the pkg-config command is one of the\ndirectories in the PATH environment variable.\n\nIf you did not expect this build to link to a pre-installed system library,\nthen check documentation of the openssl-sys crate for an option to\nbuild the library from source, or disable features or dependencies\nthat require pkg-config."

  --- stderr
  thread 'main' panicked at '

  Could not find directory of OpenSSL installation, and this `-sys` crate cannot
  proceed without this knowledge. If OpenSSL is installed and this crate had
  trouble finding it,  you can set the `OPENSSL_DIR` environment variable for the
  compilation process.

  Make sure you also have the development packages of openssl installed.
  For example, `libssl-dev` on Ubuntu or `openssl-devel` on Fedora.

  If you're in a situation where you think the directory *should* be found
  automatically, please open a bug at https://github.com/sfackler/rust-openssl
  and include information about your system as well as this message.

  $HOST = x86_64-unknown-linux-gnu
  $TARGET = x86_64-unknown-linux-gnu
  openssl-sys = 0.9.78


  It looks like you're compiling on Linux and also targeting Linux. Currently this
  requires the `pkg-config` utility to find OpenSSL but unfortunately `pkg-config`
  could not be found. If you have OpenSSL installed you can likely fix this by
  installing `pkg-config`.

  ', /home/amos/.cargo/registry/src/github.com-1ecc6299db9ec823/openssl-sys-0.9.78/build/find_normal.rs:191:5
  note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Oh no! We're missing a native dependency. This is a software package that isn't written in Rust, and that we need to install through some other means.

The error message suggests installing pkg-config, so let's do that:

$ sudo apt update
[sudo] password for amos:
(cut)
$ sudo apt install pkg-config
(cut)
Cool bear

Cool bear's hot tip

If a screen pops up in the terminal, asking "Which services should be restarted?", you can do just like in the text-mode install wizard: press the "Tab" key to move the focus to the next element until the "Ok" button is focused, and press "Enter" to.. press the button.

Now let's try again:

$ cargo check
   Compiling openssl-sys v0.9.78
error: failed to run custom build command for `openssl-sys v0.9.78`

Caused by:
  process didn't exit successfully: `/home/amos/catscii/target/debug/build/openssl-sys-8f8c0dbd6d813330/build-script-main` (exit status: 101)
  --- stdout
  cargo:rustc-cfg=const_fn
  cargo:rustc-cfg=openssl
  cargo:rerun-if-env-changed=X86_64_UNKNOWN_LINUX_GNU_OPENSSL_LIB_DIR
  X86_64_UNKNOWN_LINUX_GNU_OPENSSL_LIB_DIR unset
  cargo:rerun-if-env-changed=OPENSSL_LIB_DIR
  OPENSSL_LIB_DIR unset
  cargo:rerun-if-env-changed=X86_64_UNKNOWN_LINUX_GNU_OPENSSL_INCLUDE_DIR
  X86_64_UNKNOWN_LINUX_GNU_OPENSSL_INCLUDE_DIR unset
  cargo:rerun-if-env-changed=OPENSSL_INCLUDE_DIR
  OPENSSL_INCLUDE_DIR unset
  cargo:rerun-if-env-changed=X86_64_UNKNOWN_LINUX_GNU_OPENSSL_DIR
  X86_64_UNKNOWN_LINUX_GNU_OPENSSL_DIR unset
  cargo:rerun-if-env-changed=OPENSSL_DIR
  OPENSSL_DIR unset
  cargo:rerun-if-env-changed=OPENSSL_NO_PKG_CONFIG
  cargo:rerun-if-env-changed=PKG_CONFIG_x86_64-unknown-linux-gnu
  cargo:rerun-if-env-changed=PKG_CONFIG_x86_64_unknown_linux_gnu
  cargo:rerun-if-env-changed=HOST_PKG_CONFIG
  cargo:rerun-if-env-changed=PKG_CONFIG
  cargo:rerun-if-env-changed=OPENSSL_STATIC
  cargo:rerun-if-env-changed=OPENSSL_DYNAMIC
  cargo:rerun-if-env-changed=PKG_CONFIG_ALL_STATIC
  cargo:rerun-if-env-changed=PKG_CONFIG_ALL_DYNAMIC
  cargo:rerun-if-env-changed=PKG_CONFIG_PATH_x86_64-unknown-linux-gnu
  cargo:rerun-if-env-changed=PKG_CONFIG_PATH_x86_64_unknown_linux_gnu
  cargo:rerun-if-env-changed=HOST_PKG_CONFIG_PATH
  cargo:rerun-if-env-changed=PKG_CONFIG_PATH
  cargo:rerun-if-env-changed=PKG_CONFIG_LIBDIR_x86_64-unknown-linux-gnu
  cargo:rerun-if-env-changed=PKG_CONFIG_LIBDIR_x86_64_unknown_linux_gnu
  cargo:rerun-if-env-changed=HOST_PKG_CONFIG_LIBDIR
  cargo:rerun-if-env-changed=PKG_CONFIG_LIBDIR
  cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR_x86_64-unknown-linux-gnu
  cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR_x86_64_unknown_linux_gnu
  cargo:rerun-if-env-changed=HOST_PKG_CONFIG_SYSROOT_DIR
  cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR
  run pkg_config fail: "`\"pkg-config\" \"--libs\" \"--cflags\" \"openssl\"` did not exit successfully: exit status: 1\nerror: could not find system library 'openssl' required by the 'openssl-sys' crate\n\n--- stderr\nPackage openssl was not found in the pkg-config search path.\nPerhaps you should add the directory containing `openssl.pc'\nto the PKG_CONFIG_PATH environment variable\nNo package 'openssl' found\n"

  --- stderr
  thread 'main' panicked at '

  Could not find directory of OpenSSL installation, and this `-sys` crate cannot
  proceed without this knowledge. If OpenSSL is installed and this crate had
  trouble finding it,  you can set the `OPENSSL_DIR` environment variable for the
  compilation process.

  Make sure you also have the development packages of openssl installed.
  For example, `libssl-dev` on Ubuntu or `openssl-devel` on Fedora.

  If you're in a situation where you think the directory *should* be found
  automatically, please open a bug at https://github.com/sfackler/rust-openssl
  and include information about your system as well as this message.

  $HOST = x86_64-unknown-linux-gnu
  $TARGET = x86_64-unknown-linux-gnu
  openssl-sys = 0.9.78

  ', /home/amos/.cargo/registry/src/github.com-1ecc6299db9ec823/openssl-sys-0.9.78/build/find_normal.rs:191:5
  note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

The error message changed a little: it says to install libssl-dev on Ubuntu, so let's try that.

$ sudo apt install libssl-dev
(cut)

And run cargo check again:

$ cargo check
    Compiling openssl-sys v0.9.78
    (cut)
    Checking catscii v0.1.0 (/home/amos/catscii)
    Finished dev [unoptimized + debuginfo] target(s) in 16.85s

Okay, neat!

Now we can use reqwest in our program:

// in `src/main.rs`

#[tokio::main]
async fn main() {
    let res = reqwest::get("https://api.thecatapi.com/v1/images/search")
        .await
        .unwrap();
    println!("Status: {}", res.status());
    let body = res.text().await.unwrap();
    println!("Body: {}", body);
}

Instead of using VS Code's "Run" inlay thingy, we can do cargo run in the terminal:

$ cargo run
   Compiling once_cell v1.16.0
   Compiling itoa v1.0.4
   (cut)

   Compiling catscii v0.1.0 (/home/amos/catscii)
    Finished dev [unoptimized + debuginfo] target(s) in 32.21s
     Running `target/debug/catscii`
Status: 200 OK
Body: [{"id":"dup","url":"https://cdn2.thecatapi.com/images/dup.jpg","width":742,"height":538}]

Alright! That wasn't so bad, as promised!

Cool bear

Mhh quick point of order: why do we await twice?

The long version is in The HTTP crash course Nobody asked for. The short version is: the first await is until we get "response headers" from the server, and the second is until we get the entire "response body".

Both of these can fail, so they return Result<T, E> where T is the type we get if things go right (Response and String) and E is the type we get if things go wrong (some error type).

To illustrate, let's mess with the API URL on purpose:

    //                                            👇
    let res = reqwest::get("https://api.thecatapi-not.com/v1/images/search")
$ cargo run --quiet
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: reqwest::Error { kind: Request, url: Url { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("api.thecatapi-not.com")), port: None, path: "/v1/images/search", query: None, fragment: None }, source: hyper::Error(Connect, ConnectError("dns error", Custom { kind: Uncategorized, error: "failed to lookup address information: Name or service not known" })) }', src/main.rs:5:10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Cool bear

I see! And when can the second res.text().await.unwrap() fail?

Well, imagine the server says "I'm gonna send a response body that's 20 bytes" and then closes the TCP connection. We'd get an error then.

Cool bear

So just to clarify, right now when there's an error communicating with the cat API, the program just crashes?

Yes. It crashes safely and lets us know where and why it crashed. This isn't a segmentation fault: we're not relying on the operating system to catch a memory-related oopsie-woopsie: our program is handling its own errors like a grown-up.

Cool bear

I see.

So, let's make sure we're pointing to the right URL again:

    //                                     👇
    let res = reqwest::get("https://api.thecatapi.com/v1/images/search")

And now seems like a good time to push our code somewhere.

Committing and pushing to GitHub

Head over to https://github.com/new and create a new project (named catscii for example):

GitHub's new project page

After clicking "Create repository", GitHub shows a page with instructions on how to commit and push the code:

My new, empty catscii repo

In our case, an empty git repository has already been initialized when we did cargo new, along with a .gitignore file:

# in `.gitignore`
/target

So all we have to do is add all files to the index, make an initial commit, add our GitHub repository as a remote, and push!

amos@miles:~/catscii$ git add .
amos@miles:~/catscii$ git commit --message "Initial import"
Author identity unknown

*** Please tell me who you are.

Run

  git config --global user.email "you@example.com"
  git config --global user.name "Your Name"

to set your account's default identity.
Omit --global to set the identity only in this repository.

fatal: unable to auto-detect email address (got 'amos@miles.(none)')

Woops, nope, forgot that part — let's tell git who are first, following its instructions (the two git config --global commands it shows us, only with our actual info).

After that's done, we can find our git commit command again by pressing "Arrow Up" a couple times, or by pressing "Ctrl+R" to start a "reverse interactive search", typing "git com", which should show our previous command, and pressing "Enter":

A reverse interactive search showing our git commit command, all I have to do is press Enter now

Pressing "Enter" runs that command, and we can see all the important files got committed:

amos@miles:~/catscii$ git commit --message "Initial import"
[master (root-commit) 48affaa] Initial import
 4 files changed, 1081 insertions(+)
 create mode 100644 .gitignore
 create mode 100644 Cargo.lock
 create mode 100644 Cargo.toml
 create mode 100644 src/main.rs

And we currently only have one commit:

amos@miles:~/catscii$ git log
commit 48affaaf517893499b619babda13b745d3831414 (HEAD -> master)
Author: Amos Wenger <spam@be.gone>
Date:   Thu Nov 24 12:36:01 2022 +0000

    Initial import

We can now add the GitHub repository as a "remote" named "origin":

amos@miles:~/catscii$ git remote add origin git@github.com:fasterthanlime/catscii.git

This prints nothing, how do we know it worked? By listing remotes:

amos@miles:~/catscii$ git remote
origin
amos@miles:~/catscii$ git remote get-url origin
git@github.com:fasterthanlime/catscii.git

Now, let's see where we're at with git status:

amos@miles:~/catscii$ git status
On branch master
nothing to commit, working tree clean

Apparently, Git on Ubuntu 22.04 still defaults to using master for the default branch name. I prefer main, so let's fix the Git setting:

amos@miles:~/catscii$ git config --global init.defaultBranch main

This is, again, silent, how do we know it worked?

amos@miles:~/catscii$ cat ~/.gitconfig
[user]
        email = spam@be.gone
        name = Amos Wenger
[init]
        defaultBranch = main

Very well. Now all other repositories we create will have main as a default branch.

As for this one, we can rename our master branch to main with git branch:

amos@miles:~/catscii$ git branch --move --force main
amos@miles:~/catscii$ git status
On branch main
nothing to commit, working tree clean

main is now the only branch:

amos@miles:~/catscii$ git branch --l
* main

And the history is still the same:

amos@miles:~/catscii$ git log
commit 48affaaf517893499b619babda13b745d3831414 (HEAD -> main)
Author: Amos Wenger <spam@be.gone>
Date:   Thu Nov 24 12:36:01 2022 +0000

    Initial import

Time to push to origin, (we can also use -u, the short form of --set-upstream), which will instruct Git to remember that the main branch should be pushed to the remote called origin (the only remote we'll have in this series).

amos@miles:~/catscii$ git push --set-upstream origin main
Enumerating objects: 7, done.
Counting objects: 100% (7/7), done.
Compressing objects: 100% (5/5), done.
Writing objects: 100% (7/7), 7.65 KiB | 3.83 MiB/s, done.
Total 7 (delta 0), reused 0 (delta 0), pack-reused 0
To github.com:fasterthanlime/catscii.git
 * [new branch]      main -> main
Branch 'main' set up to track remote branch 'main' from 'origin'.

And now our files show up in GitHub's web UI!

GitHub web UI, we can see the src folder, .gitignore, Cargo.lock, Cargo.toml

Adding a README

We're missing a README.md file, let's add it now. We can either run code README.md in the integration terminal, or right click in the Explorer (top-left), choosing "New file".

Let's fill it with this:

# catscii

Serves cat pictures as ASCII art over the internet.

Then stage, commit and push:

amos@miles:~/catscii$ git add .
amos@miles:~/catscii$ git commit --message "Add README"
[main 96c8ab9] Add README
 1 file changed, 3 insertions(+)
 create mode 100644 README.md
amos@miles:~/catscii$ git push
Enumerating objects: 4, done.
Counting objects: 100% (4/4), done.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 431 bytes | 431.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
To github.com:fasterthanlime/catscii.git
   48affaa..96c8ab9  main -> main

This time we were able to push with just git push, since we told it to remember (with -u on our first git push).

And the README shows up on GitHub, too:

The README, rendered on GitHub's web UI

Now that our work is safely stored in The Cloud ☁️, let's proceed.

Deserializing that JSON response

The response we got from the Cat API is JSON:

[
  {
    "id": "ap8",
    "url": "https://cdn2.thecatapi.com/images/ap8.jpg",
    "width": 605,
    "height": 807
  }
]

Here it is formatted neatly:

[
  {
    "id": "ap8",
    "url": "https://cdn2.thecatapi.com/images/ap8.jpg",
    "width": 605,
    "height": 807
  }
]

The only thing we really care about here is the url. Also, the whole response is an array.

Whereas in a dynamic language like JavaScript, Python or Ruby, the JSON response would be dynamically typed, and we'd do something like res[0].url to grab the result, in Rust, we like to declare the shape of the data and let something like serde_json "deserialize" it, which validates that the structure of the data is what we expect, and gives us strongly-typed values to manipulate.

reqwest has built-in support for that (because we enabled the json feature: that was the -F json in the cargo add reqwest earlier), we just need to add the serde crate with the derive feature to be able to declare our types:

amos@miles:~/catscii$ cargo add serde@1 --features derive
    Updating crates.io index
      Adding serde v1.0.147 to dependencies.
             Features:
             + derive
             + serde_derive
             + std
             - alloc
             - rc
             - unstable

In our src/main.rs, above the main function, we can add this:

use serde::Deserialize;

#[derive(Deserialize)]
struct CatImage {
    id: String,
    // this is the only field we really need, but let's show how we would
    // deserialize the whole thing anyway.
    url: String,
    width: usize,
    height: usize,
}

And then, inside the main function, use res.json() instead of res.text():

#[tokio::main]
async fn main() {
    let res = reqwest::get("https://api.thecatapi.com/v1/images/search")
        .await
        .unwrap();
    if !res.status().is_success() {
        panic!("Request failed with HTTP {}", res.status());
    }

    //              👇
    let images: Vec<CatImage> = res.json().await.unwrap();
    let image = images
        .first()
        .expect("the cat API should return at least one image");
    println!("The image is at {}", image.url);
}

It's important to note that we're specifying the type of images to be a Vec<CatImage> — because the JSON response is an array.

Let's run this:

amos@miles:~/catscii$ cargo run --quiet
warning: fields `id`, `width` and `height` are never read
 --> src/main.rs:5:5
  |
4 | struct CatImage {
  |        -------- fields in this struct
5 |     id: String,
  |     ^^
6 |     url: String,
7 |     width: usize,
  |     ^^^^^
8 |     height: usize,
  |     ^^^^^^
  |
  = note: `#[warn(dead_code)]` on by default

The image is at https://cdn2.thecatapi.com/images/b5j.jpg

Now that we've seen how we could deserialize all the fields, since we're not going to need them all, we can remove the unnecessary ones.

Also, because we're never going to need that CatImage struct outside of that function, we can move it in the scope:

#[tokio::main]
async fn main() {
    let res = reqwest::get("https://api.thecatapi.com/v1/images/search")
        .await
        .unwrap();
    if !res.status().is_success() {
        panic!("Request failed with HTTP {}", res.status());
    }

    #[derive(Deserialize)]
    struct CatImage {
        url: String,
    }
    let images: Vec<CatImage> = res.json().await.unwrap();
    let image = images
        .first()
        .expect("the cat API should return at least one image");
    println!("The image is at {}", image.url);
}

Just like before, we can stage, commit, and push that.

amos@miles:~/catscii$ git add . && git commit -m "Deserialize JSON response" && git push
[main 44c34d7] Deserialize JSON response
 3 files changed, 31 insertions(+), 3 deletions(-)
Enumerating objects: 11, done.
Counting objects: 100% (11/11), done.
Compressing objects: 100% (5/5), done.
Writing objects: 100% (6/6), 1016 bytes | 1016.00 KiB/s, done.
Total 6 (delta 2), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (2/2), completed with 2 local objects.
To github.com:fasterthanlime/catscii.git
   96c8ab9..44c34d7  main -> main
Cool bear

Cool bear's hot tip

VS Code has a graphical interface to Git — it's the icon (on the left bar) that looks slightly like a graph. It's decent, but knowing how to use the Git command line is also very useful.

Use whichever you want moving forward.

Error handling

Because eventually this code is going to be in an HTTP handler, not just in a command-line program, we might not want to call .unwrap() so much.

It'd be better if all the "business logic" lived in one fallible function (a function that returns a Result<T, E>), and we could still "make sure things went right or crash" from the main function, once.

We'll use color-eyre for that:

amos@miles:~/catscii$ cargo add color-eyre@0.6
    Updating crates.io index
      Adding color-eyre v0.6.2 to dependencies.
             Features:
             + capture-spantrace
             + color-spantrace
             + tracing-error
             + track-caller
             - issue-url
             - url
use serde::Deserialize;

#[tokio::main]
async fn main() {
    let url = get_cat_image_url().await.unwrap();
    println!("The image is at {}", url);
}

async fn get_cat_image_url() -> color_eyre::Result<String> {
    let api_url = "https://api.thecatapi.com/v1/images/search";
    let res = reqwest::get(api_url).await?;
    if !res.status().is_success() {
        return Err(color_eyre::eyre::eyre!(
            "The Cat API returned HTTP {}",
            res.status()
        ));
    }

    #[derive(Deserialize)]
    struct CatImage {
        url: String,
    }
    let images: Vec<CatImage> = res.json().await?;
    // this syntax is new in Rust 1.65
    let Some(image) = images.first() else {
        return Err(color_eyre::eyre::eyre!("The Cat API returned no images"));
    };

    Ok(image.url)
}
Cool bear

Cool bear's hot tip

The let-else syntax is new in 1.65.0, see the announcement.

At the time of this writing, it appears rustfmt is not able to format it. It's okay, we'll use it anyway.

By the way, you should have "Format on save" enabled for Rust. Open the command palette (F1 / Cmd+Shift+P), search for "Open User Settings (JSON)" and add the following:

{
  "[rust]": {
    "editor.formatOnSave": true
  }
}

While we're adjusting settings, run the "Open User Settings" (without JSON this time), search for "check command" and set "Rust-analyzer > Check On Save: Command" to "clippy" (it defaults to "check").

Kind of an odd time to suggest this, since we just "broke" rustfmt, but hopefully the situation resolves itself in the future (cf. the tracking issue).

Beautiful! No more .unwrap() in get_cat_image_url. It can fail one of four ways:

  1. We're not getting a response at all
  2. The response we got indicates an HTTP error (the status isn't 2xx)
  3. The JSON payload doesn't have the shape we expect
  4. The JSON payload is an empty array

However, that code doesn't compile. After saving, we can see the error inline thanks to the "Error Lens" extension:

VS Code screenshot, the image.url (let line of get_cat_image_url) is highlighted and red text follows, saying we cannot move out of image.url which is behind a shared reference

I have "inlay type hints" enabled here, which tells us what the problem is: image is of type &CatImage: it's "borrowed" from the Vec (images). If we were allowed to return that, images would "drop", the associated memory would be freed, and the reference we returned would point to freed memory.

Because we're not doing anything else with images here, we can simply move one CatImage out of it, instead of borrowing from it. There's a bunch of ways to do that.

Here's one, turning the Vec into an Iterator<Item = CatImage> and grabbing the first item:

    let Some(image) = images.into_iter().next() else {
        return Err(color_eyre::eyre::eyre!("The Cat API returned no images"));
    };

Here's a simpler one: just calling the Vec::pop method:

    let Some(image) = images.pop() else {
        return Err(color_eyre::eyre::eyre!("The Cat API returned no images"));
    };

That one doesn't compile either though, it complains that images cannot be "borrowed as mutable" — and that's because in Rust, we must "opt into" bindings being mutable: they're immutable by default.

Here's the fixed code:

    let mut images: Vec<CatImage> = res.json().await?;
    let Some(image) = images.pop() else {
        return Err(color_eyre::eyre::eyre!("The Cat API returned no images"));
    };

Now, everything compiles again, and the program runs. Time to save our work again.

git add . && git commit -m "Error handling" && git push
[main 5920aef] Error handling
 3 files changed, 186 insertions(+), 21 deletions(-)
 rewrite src/main.rs (67%)
Enumerating objects: 11, done.
Counting objects: 100% (11/11), done.
Compressing objects: 100% (5/5), done.
Writing objects: 100% (6/6), 2.12 KiB | 2.12 MiB/s, done.
Total 6 (delta 2), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (2/2), completed with 2 local objects.
To github.com:fasterthanlime/catscii.git
   44c34d7..5920aef  main -> main

Downloading the cat image

So, the cat API gives us an URL: because we'll want to decode the image and turn it into ASCII, we'll need to download it. We already know how to do that with reqwest, so, let's.

This'll give us a JPEG or PNG blob, let's add the pretty-hex crate to be able to dump part of it as ASCII+hexadecimal:

amos@miles:~/catscii$ cargo add pretty-hex@0.3
    Updating crates.io index
      Adding pretty-hex v0.3 to dependencies.
             Features as of v0.3.0:
             + alloc

And let's go!

use pretty_hex::PrettyHex;
use serde::Deserialize;

#[tokio::main]
async fn main() {
    let image_bytes = get_cat_image_bytes().await.unwrap();
    // only dump the first 200 bytes so our terminal survives the
    // onslaught. this will panic if the image has fewer than 200 bytes.
    println!("{:?}", &image_bytes[..200].hex_dump());
}

async fn get_cat_image_bytes() -> color_eyre::Result<Vec<u8>> {
    #[derive(Deserialize)]
    struct CatImage {
        url: String,
    }

    let api_url = "https://api.thecatapi.com/v1/images/search";
    let client = reqwest::Client::default();

    let image = client
        .get(api_url)
        .send()
        .await?
        .error_for_status()?
        .json::<Vec<CatImage>>()
        .await?
        .pop()
        .ok_or_else(|| color_eyre::eyre::eyre!("The Cat API returned no images"))?;

    Ok(client
        .get(image.url)
        .send()
        .await?
        .error_for_status()?
        .bytes()
        .await?
        .to_vec())
}

The code suddenly looks very different — we have two requests to make, and we chain a lot of method calls in a way we didn't before.

It's easier to read in your code editor, with inlay hints enabled (look through the VS Code user settings if needed).

This is what you should be seeing:

The code we just wrote, but in VS Code, with inlay type hints

First off, we're re-using the same reqwest::Client to make our two requests, instead of using the freestanding reqwest::get function. This doesn't matter much here (because the API and the CDN have different domains, at the time of this writing), but it will when this becomes server code.

Then, we build a request (client.get(url).send()), await response headers, deal with network errors here, use the error_for_status method to turn the whole thing into a Result<T, E> with an error if the HTTP status isn't 200 (in case the server didn't like our request, requires authentication, has encountered some problems, etc.).

We then receive the body and deserialize it as JSON, using a turbofish to let the compiler know what type it should deserialize to, await that and propagate errors if any (with the ? "sigil"), then grab the last item with Vec::pop, and turn the Option<T> returned by pop into a Result<T, E> if it was None (ie. we had an empty array), and propagate errors once again.

We do something similar for the second request, except we call .bytes() on the request instead of .json(), because we're dealing with binary data now.

The function was renamed get_cat_image_bytes, and it now returns a color_eyre::Result<Vec<u8>> (an alias for Result<Vec<u8>, color_eyre::Report>). Because .bytes() return a reference-counted Bytes type that comes from another crate, we call .to_vec() to copy it into a more standard type.

All clear? If not, surely I gave you things to search for / ask about.

Let's see if it works!

amos@miles:~/catscii$ cargo run --quiet
Length: 200 (0xc8) bytes
0000:   ff d8 ff e0  00 10 4a 46  49 46 00 01  01 00 00 01   ......JFIF......
0010:   00 01 00 00  ff e1 00 34  45 78 69 66  00 00 49 49   .......4Exif..II
0020:   2a 00 08 00  00 00 01 00  69 87 04 00  01 00 00 00   *.......i.......
0030:   1a 00 00 00  00 00 00 00  01 00 03 a4  03 00 01 00   ................
0040:   00 00 00 00  00 00 00 00  00 00 ff e2  0c 58 49 43   .............XIC
0050:   43 5f 50 52  4f 46 49 4c  45 00 01 01  00 00 0c 48   C_PROFILE......H
0060:   4c 69 6e 6f  02 10 00 00  6d 6e 74 72  52 47 42 20   Lino....mntrRGB
0070:   58 59 5a 20  07 ce 00 02  00 09 00 06  00 31 00 00   XYZ .........1..
0080:   61 63 73 70  4d 53 46 54  00 00 00 00  49 45 43 20   acspMSFT....IEC
0090:   73 52 47 42  00 00 00 00  00 00 00 00  00 00 00 00   sRGB............
00a0:   00 00 f6 d6  00 01 00 00  00 00 d3 2d  48 50 20 20   ...........-HP
00b0:   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00   ................
00c0:   00 00 00 00  00 00 00 00                             ........

Nice! The "JFIF" we see here indicates we got a JPEG file.

If we try again, we get a different one:

amos@miles:~/catscii$ cargo run --quiet
Length: 200 (0xc8) bytes
0000:   ff d8 ff e0  00 10 4a 46  49 46 00 01  01 00 00 01   ......JFIF......
0010:   00 01 00 00  ff db 00 43  00 06 04 04  05 04 04 06   .......C........
0020:   05 05 05 06  06 06 07 09  0e 09 09 08  08 09 12 0d   ................
0030:   0d 0a 0e 15  12 16 16 15  12 14 14 17  1a 21 1c 17   .............!..
0040:   18 1f 19 14  14 1d 27 1d  1f 22 23 25  25 25 16 1c   ......'.."#%%%..
0050:   29 2c 28 24  2b 21 24 25  24 ff db 00  43 01 06 06   ),($+!$%$...C...
0060:   06 09 08 09  11 09 09 11  24 18 14 18  24 24 24 24   ........$...$$$$
0070:   24 24 24 24  24 24 24 24  24 24 24 24  24 24 24 24   $$$$$$$$$$$$$$$$
0080:   24 24 24 24  24 24 24 24  24 24 24 24  24 24 24 24   $$$$$$$$$$$$$$$$
0090:   24 24 24 24  24 24 24 24  24 24 24 24  24 24 ff c2   $$$$$$$$$$$$$$..
00a0:   00 11 08 02  7b 01 f4 03  01 22 00 02  11 01 03 11   ....{...."......
00b0:   01 ff c4 00  1b 00 00 03  01 01 01 01  01 00 00 00   ................
00c0:   00 00 00 00  00 00 00 01                             ........

That one's fun! Doesn't have color profiles (no XICC_PROFILE string), and look at all these "24" bytes.

Time to save our work once again — you know what to do. We're starting to have a nice history of our project:

The history of the catscii repository on GitHub

Turning cats into ASCII art

I debated implementing our own ASCII art renderer, but that's not really the focus of this series. Part of the value in using Rust for this project is that we can tap into a rich ecosystem of existing crates, so, let's do that!

amos@miles:~/catscii$ cargo add --no-default-features artem@1
    Updating crates.io index
      Adding artem v1 to dependencies
Cool bear

Cool bear's hot tip

Note the --no-default-features here: as it turns out, the artem crate has a "webimage" feature that uses _another HTTP client to allow fetching images from the web. We don't need, or want, that.

The artem API takes a DynamicImage which is a type from the image crate, so let's add it too:

amos@miles:~/catscii$ cargo add image@0.24
    Updating crates.io index
      Adding image v0.24 to dependencies.
             Features as of v0.24.0:
             + bmp
             + dds
             + dxt
             + exr
             + farbfeld
             + gif
             + hdr
             + ico
             + jpeg
             + jpeg_rayon
             + openexr
             + png
             + pnm
             + scoped_threadpool
             + tga
             + tiff
             + webp
             - avif
             - avif-decoder
             - avif-encoder
             - benchmarks
             - dav1d
             - dcv-color-primitives
             - mp4parse
             - ravif
             - rgb

We can see that jpeg and png are enabled by default, that's good. We could probably disable a few features we don't need (I doubt the cat API serves DDS/DXT textures...), but let's not concern ourselves with that for now.

Our full program becomes:

use pretty_hex::PrettyHex;
use serde::Deserialize;

#[tokio::main]
async fn main() {
    let art = get_cat_ascii_art().await.unwrap();
    println!("{art}");
}

async fn get_cat_ascii_art() -> color_eyre::Result<String> {
    #[derive(Deserialize)]
    struct CatImage {
        url: String,
    }

    let api_url = "https://api.thecatapi.com/v1/images/search";
    let client = reqwest::Client::default();

    let image = client
        .get(api_url)
        .send()
        .await?
        .error_for_status()?
        .json::<Vec<CatImage>>()
        .await?
        .pop()
        .ok_or_else(|| color_eyre::eyre::eyre!("The Cat API returned no images"))?;

    let image_bytes = client
        .get(image.url)
        .send()
        .await?
        .error_for_status()?
        .bytes()
        .await?;

    let image = image::load_from_memory(&image_bytes)?;
    let ascii_art = artem::convert(image, artem::options::OptionBuilder::new().build());

    Ok(ascii_art)
}
Cool bear

GATs! GATs! GATs!

No, bear: cats. We already got GATs.

Cool bear

GATs got got? Nice!

Without further ado, I present: cats.

A barely-recognizable ASCII art cat

It's very hit and miss: not all pictures are well-suited for ASCIIfication, but: it's good enough for now.

Time to save our work and prepare for the next part of our journey.

Comment on /r/fasterthanlime

(JavaScript is required to see this. Or maybe my stuff broke)

Here's another article just for you:

Proc macro support in rust-analyzer for nightly rustc versions

I don't mean to complain. Doing software engineering for a living is a situation of extreme privilege. But there's something to be said about how alienating it can be at times.

Once, just once, I want to be able to answer someone's "what are you working on?" question with "see that house? it wasn't there last year. I built that".