Printing ASCII cats to the terminal
đ This page was last updated ~2 years ago. Just so you know.
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:
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:
Clicking the "Run".. button? Ghost button? Inlay? Thingy on top of main launches our program in a new integrated terminal:
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.
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"); }
But Amos, what does this attribute do?
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)
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!
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
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.
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.
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):
After clicking "Create repository", GitHub shows a page with instructions on how to commit and push the code:
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":
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!
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:
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
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) }
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:
- We're not getting a response at all
- The response we got indicates an HTTP error (the status isn't 2xx)
- The JSON payload doesn't have the shape we expect
- 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:
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:
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:
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
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) }
GATs! GATs! GATs!
No, bear: cats. We already got GATs.
GATs got got? Nice!
Without further ado, I present: cats.
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.
Thanks to my sponsors: Raine Tingley, Sean Bryant, Marty Penner, Zoran Zaric, Garret Kelly, Andy F, Justin Ossevoort, James Rhodes, Peter Shih, Noel, Beth Rennie, Lyssieth, Philipp Gniewosz, David White, Aiden Scandella, Hadrien G., Max Heaton, Leo Shimonaka, Aalekh Patel, HorvĂĄth-LĂĄzĂĄr PĂ©ter and 227 more
If you liked what you saw, please support my work!
Here's another article just for you:
It feels like an eternity since I've started using Rust, and yet I remember vividly what it felt like to bang my head against the borrow checker for the first few times.
I'm definitely not alone in that, and there's been quite a few articles on the subject! But I want to take some time to present the borrow checker from the perspective of its , rather than as an opponent to fend with.