Chapter 2. A real (small) program
The official Rust book goes right into some code at this point. But rather than directly rehash what the official Rust book covers, let's dive directly into the full code of a different example. Who doesn't love two for the price of one?
So in this chapter, we'll write and use a program to fetch a joke and print it to the console. If the user doesn't like the joke, they can get a new one.
Ready? Let's go!
"Beware of bugs in the above code; I have only proved it correct, not tried it." – Donald Knuth
Code for "Make me a joke"
Start by running cargo new make_joke && cd make_joke.
Then open src/main.rs and add the following code.
use reqwest;
use serde_json::Value;
use std::error::Error;
use std::io;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
println!("👋 Welcome to 'Random Joke Fetcher!'");
loop {
let joke = fetch_joke().await?;
print_joke(&joke);
println!("Would you like another joke? (Y/N)");
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.expect("Failed to read line");
match input.trim().to_uppercase().as_str() {
"Y" => continue,
"N" => break,
_ => {
println!("Invalid input. Exiting.");
break;
}
}
}
Ok(())
}
async fn fetch_joke() -> Result<Value, Box<dyn Error>> {
let url = "https://official-joke-api.appspot.com/jokes/random";
println!("Fetching a random joke...");
let response = reqwest::get(url).await?;
let joke = response.json().await?;
Ok(joke)
}
fn print_joke(joke: &Value) {
let setup = joke["setup"].as_str().unwrap_or("Oops, couldn't fetch the setup.");
let punchline = joke["punchline"].as_str().unwrap_or("Oops, couldn't fetch the punchline.");
println!("🃏 Here's a joke for you:");
println!("{}", setup);
println!("{}", punchline);
}
When you run this you'll get an error because reqwest, serde_json, serde and tokio are not dependencies yet.
➜ make_joke git:(develop) ✗ cargo run
Compiling make_joke v0.1.0 (/Users/jason/repos/rust-with-jason-deploy/make_joke)
error[E0432]: unresolved import `reqwest`
--> src/main.rs:1:5
|
1 | use reqwest;
| ^^^^^^^ no external crate `reqwest`
error[E0432]: unresolved import `serde_json`
--> src/main.rs:2:5
|
2 | use serde_json::Value;
| ^^^^^^^^^^ use of unresolved module or unlinked crate `serde_json`
|
= help: if you wanted to use a crate named `serde_json`, use `cargo add serde_json` to add it to your `Cargo.toml`
error[E0433]: failed to resolve: use of unresolved module or unlinked crate `tokio`
--> src/main.rs:6:3
|
6 | #[tokio::main]
| ^^^^^ use of unresolved module or unlinked crate `tokio`
error[E0282]: type annotations needed
--> src/main.rs:38:9
|
38 | let response = reqwest::get(url).await?;
| ^^^^^^^^
39 | let joke = response.json().await?;
| -------- type must be known at this point
|
help: consider giving `response` an explicit type
|
38 | let response: /* Type */ = reqwest::get(url).await?;
| ++++++++++++
error[E0752]: `main` function is not allowed to be `async`
--> src/main.rs:7:1
|
7 | async fn main() -> Result<(), Box<dyn Error>> {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`
Some errors have detailed explanations: E0282, E0432, E0433, E0752.
For more information about an error, try `rustc --explain E0282`.
error: could not compile `make_joke` (bin "make_joke") due to 5 previous errors
Notice how helpful the error messages are. For example, this one tells us to install serde_json:
= help: if you wanted to use a crate named `serde_json`, use `cargo add serde_json` to add it to your `Cargo.toml`
Let's get rid of these errors and make this compile.
Add the dependencies
To make an async (non-blocking) call with reqwest we need tokio. We also need serde_json to deserialize the JSON returned from our joke endpoint.
Edit your Cargo.toml directly:
[dependencies]
reqwest = { version = "0.12", features = ["json", "blocking"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
This is where you'll normally place your dependencies. Though you can add them with Cargo via the CLI, because we need to specify certain features for some libraries above, we add them to the Cargo.toml file directly for simplicity.
Play the make_joke program
Compile and run the program with the usual cargo run command. You should see some output like the following:
👋 Welcome to 'Random Joke Fetcher!'
Fetching a random joke...
🃏 Here's a joke for you:
Want to hear a chimney joke?
Got stacks of em! First one's on the house
Would you like another joke? (Y/N)
Line-by-line breakdown
Let's breakdown our new Make me a joke Rust program.
In short, this program fetches random jokes from an online API and displays them to the user. It's simple, not hardened for a billion users but just right for you and your terminal.
We use some external dependencies for addicitonal functionality, including the use of asynchronous programming with the tokio crate, HTTP requests with reqwest, and JSON handling with serde_json.
Importing dependencies
use reqwest;
use serde_json::Value;
use std::error::Error;
use std::io;
In the above you can see how we begin the file by importing the necessary crates and modules. reqwest is for making HTTP requests, serde_json for handling JSON data, std::error::Error for error handling, and std::io for input/output operations. You'll see this in all Rust programs.
Main function with Tokio
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
println!("👋 Welcome to 'Random Joke Fetcher!'");
Here, we declare our main function as asynchronous using the #[tokio::main] attribute. This allows us to run asynchronous code. The function returns a Result type with a generic error. Though this uses async, you'll see less of that throughout this book as we focus on the meat and potatoes of Rust. In real code that calls web or other endpoints, you'll see it all the time.
Main loop
loop {
let joke = fetch_joke().await?;
print_joke(&joke);
We enter an infinite loop where we fetch a joke and print it. The await keyword is used to wait for the asynchronous fetch_joke function to complete - that's the part that calls a web API.
As you can see, declaring a loop in Rust is clear and concise.
User interaction
println!("Would you like another joke? (Y/N)");
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.expect("Failed to read line");
match input.trim().to_uppercase().as_str() {
"Y" => continue,
"N" => break,
_ => {
println!("Invalid input. Exiting.");
break;
}
}
}
Ok(())
}
We ask the user if they want another joke. The user's input is read from standard input, trimmed, and converted to uppercase.
Depending on the input, we either continue the loop for another joke, break out of the loop, or handle invalid input by exiting the program. Importantly, we use the match syntax of Rust, which allows for pattern matching, enabling the execution of different code blocks based on the structure and value of an expression, providing a powerful and concise way to handle various conditions.
Fetching the joke
async fn fetch_joke() -> Result<Value, Box<dyn Error>> {
let url = "https://official-joke-api.appspot.com/jokes/random";
println!("Fetching a random joke...");
let response = reqwest::get(url).await?;
let joke = response.json().await?;
Ok(joke)
}
The fetch_joke function is defined as asynchronous and returns a Result with a JSON Value. As in most programming languages, we use functions in Rust to separate clear-cut functionality. Here, all this function does is get the joke.
We define the URL for the joke API and print a message indicating that a joke is being fetched. In real-world software this would surely be in some type of configuration variable. Lastly, we use reqwest to make an HTTP GET request and wait for the response. The response is then parsed as JSON using serde_json.
Printing the joke
fn print_joke(joke: &Value) {
let setup = joke["setup"].as_str().unwrap_or("Oops, couldn't fetch the setup.");
let punchline = joke["punchline"].as_str().unwrap_or("Oops, couldn't fetch the punchline.");
println!("🃏 Here's a joke for you:");
println!("{}", setup);
println!("{}", punchline);
}
The print_joke function takes a reference to a JSON Value and extracts the setup and punchline fields. If these fields are not found, default error messages are used. Finally, the joke is printed to the console using the built-in println! macro.
Summary
This simple yet fun Rust program demonstrates the power of asynchronous programming, making HTTP requests, and handling JSON data. We use loops, variables, macros, match statements and function blocks, critical components of the Rust language that you can use to write software. In the proceeding pages we'll go much deeper into these concepts.
Try modifying the program as you see fit; maybe start with changing the CLI prompts and end by using another API with different data in response.
Awesome details
Our first program showcases several powerful features of the language, emphasizing the elegance and efficiency of Rust's design, syntax and standard library. Let's investigate some of those in more detail.
Asynchronous programming with Tokio
By using #[tokio::main], we declare our main function as asynchronous, allowing us to write non-blocking code. If you're a web-engineer, you deal with asynchronous software and architectures for nearly all of your work.
For those who are not familiar, asynchronous functionality is particularly useful for network requests where waiting for a response can be done efficiently without freezing the rest of the program. In modern times, pretty much the whole of the internet is built using async.
The await keyword seamlessly integrates with async functions, providing a clear and concise way to handle asynchronous operations.
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
println!("👋 Welcome to 'Random Joke Fetcher!'");
// ...
}
HTTP requests with Reqwest
The reqwest crate simplifies the process of making HTTP requests. In our program, the fetch_joke function performs an asynchronous GET request to fetch a random joke. The combination of reqwest and tokio makes handling web requests straightforward and efficient, enabling the program to fetch data from the internet without blocking the main thread. You'll see these library frameworks everywhere - they are very common throughout the Rust ecosystem.
async fn fetch_joke() -> Result<Value, Box<dyn Error>> {
let url = "https://official-joke-api.appspot.com/jokes/random";
println!("Fetching a random joke...");
let response = reqwest::get(url).await?;
let joke = response.json().await?;
Ok(joke)
}
JSON handling with Serde
The serde_json crate is used to parse the JSON response from the joke API. By converting the response to a Value type, we can easily access the necessary fields (setup and punchline) using simple indexing.
This highlights Rust's ability to integrate with powerful serialization/deserialization libraries, making it easier to work with various data formats.
fn print_joke(joke: &Value) {
let setup = joke["setup"].as_str().unwrap_or("Oops, couldn't fetch the setup.");
let punchline = joke["punchline"].as_str().unwrap_or("Oops, couldn't fetch the punchline.");
println!("🃏 Here's a joke for you:");
println!("{}", setup);
println!("{}", punchline);
}
User interaction
Rust's standard library provides robust tools for handling user input.
Using std::io, we read input from the console and process it with string methods like trim and to_uppercase. The match syntax is then employed to determine the user's choice, showcasing Rust's pattern matching capabilities. This results in clear and concise code for handling multiple input scenarios.
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.expect("Failed to read line");
match input.trim().to_uppercase().as_str() {
"Y" => continue,
"N" => break,
_ => {
println!("Invalid input. Exiting.");
break;
}
}
As we proceed further into the language, you'll see that Rust's pattern matching syntax becomes part of how we think and reason about our code. It's incredibly powerful and is one of the hallmark features of the language and its ecosystem.
Error handling
Rust's emphasis on safety is evident in the program's error handling. By returning Result types and using the ? operator, errors are propagated upwards, ensuring that any issues are properly addressed. This approach minimizes the chances of unexpected crashes and maintains code clarity.
async fn fetch_joke() -> Result<Value, Box<dyn Error>> {
let response = reqwest::get(url).await?;
let joke = response.json().await?;
Ok(joke)
}
Clean, safe and readable code
In this breakdown, you can readily see Rust's focus on safety, readability and efficiency. Each part of the code is designed to be both performant and easy to read, making it an excellent example of Rust's capabilities in building reliable and interactive command-line applications.