Hello World!

In this chapter you will create a simple RESTful webservice, returning nothing more than a "hello world" message, formatted as JSON (Javascript Simple Object Notation).

We will go through the steps to set up the development environment and a simple project.

Setup the development environment

During the development of this book we used Ubuntu Linux to test the examples and I generally recommend to use Linux for Rust development.

You will also need a good IDE. It is possible to write Rust code with a text editor like vi or emacs, but trust me, an IDE will make your life much easier, especially in the beginning. We usually use VS Code with the rust-analyzer extension or JetBrains RustRover.

Use should also install git to be able to manage your source code repositories.

We used Rust version 1.75 during the development of this book.

Install Rust

First things first: you have to install Rust if you have not done it yet.

The installation process is documented on rust-lang.org and in the Rust Book

A quick recap:

Linux

Just run this script:

$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Mac

First install a compiler:

$ xcode-select --install

Then run this script:

$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Windows

On windows you should probably use one of the standalone installers from here:

https://forge.rust-lang.org/infra/other-installation-methods.html#standalone-installers

Or simply start a WSL2 environment and work with Rust within that Linux environment.

Setup the workspace

I usually prefer to start the project with a multi-package workspace. This way we can split our application into smaller, loosely coupled parts but we don't have to maintain a different repository for each of them and we can work with the whole codebase in one IDE window.

You can find the sample codes on GitHub

Create a multi-package workspace

To start our new project, let's create a directory to hold the workspace:

$ mkdir hello-world
$ cd hello-world

and create a Cargo.toml file there:

[workspace]

members = [
  "hello_main"
]

Newer versions of cargo will probably display a warning about the resolver version. You should add this line to your Cargo.toml to prevent this:

[workspace]
resolver = "2"

This indicates that we opt in to use the new feature resolver introduced in Rust 1.51. The new resolver is the default starting with the 2021 edition of the Rust language.

This configuration indicates that we have a single package for now, the hello_main package. Also initialize a git repository, to prevent cargo new from creating a new repository for every package:

$ git init

I use JetBrains RustRover so I usually add a .gitignore file to ignore the .idea folder and the Rust target folder:

.idea
target

If you use VS Code then you should add these folders to .gitignore:

.vscode
.history

Now we can create the new package with cargo:

cargo new hello_main

If the operating system does not find the cargo executable check your Rust installation. Maybe your PATH does not contain the folder where rustup installed the binaries.

You can find further troubleshooting tips here: Installation Troubleshooting

Now our directory structure should look like this:

.git
.gitignore
Cargo.toml
hello_main/
  Cargo.toml
  src/
    main.rs

The cargo utility created the hello_main folder and a new Cargo.toml in it:

[package]
name = "hello_main"
version = "0.1.0"
edition = "2021"

[dependencies]

It contains the package name, a version number and indicates that we use the 2021 edition of the Rust language. The dependencies section is empty for now.

Cargo also creates an src folder and an initial main.rs source file in it:

fn main() {
    println!("Hello, world!");
}

Now we can run cargo build in the main workspace directory. The build creates an executable in target/debug/hello_main and a single Cargo.lock file in the main workspace directory. The Cargo.lock file locks our dependencies to specific versions.

Try to execute ./target/debug/hello_main, it just prints a Hello, world! message to the console.

Let's commit our code:

$ git add .gitignore Cargo.lock Cargo.toml hello_main/ 
$ git commit -m 'workspace setup'

A simple Axum webserver

We will use the axum crate to build our web services. At the time I write this book the current version is 0.7.4, so add this to the hello_main/Cargo.toml file:

[dependencies]
axum = "0.7.4"

Run cargo build to download our dependencies and build the hello_main binary. If you take a look at the Cargo.lock file generated in the root directory of the project, you will see that it includes a lot more packages, not just axum. These are the dependencies of axum. We will use some of them directly in our project, the tokio crate for example is required to start an async runtime, so add that one too:

[dependencies]
axum = "0.7.4"
tokio =  { version = "1.35.1", features = ["full"] }

Notice that this line is slightly different: we specified not only a version number for tokio, but a list of features too. The tokio crate has many optional parts, these are the so-called features. For the sake of simplicity we enabled most of them at once with the full flag.

Now open hello_main/src/main.rs, and change our main function into an async one:

#[tokio::main]
async fn main() {
    println!("Hello, world!");
}

The #[tokio::main] macro starts an async runtime for us and the whole main function will run as an async function above that runtime. Later we will see in detail what this macro does and how can you customize the created runtime.

If you run cargo build in the project root directory again and execute the resulting binary in ./target/debug/hello_main, you will see that is just works the way it did earlier.

Now create our first axum request handler in main.rs:

async fn hello() -> &'static str {
    "Hello, world!"
}

That's quite simple, just returns a static string slice, containing Hello, world!.

Now setup the routing for our web service in the main function, and start a simple server:

async fn main() {
    let app = Router::new()
        .route("/", get(hello));
        
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

The route method binds the hello function to the GET HTTP verb on the '/' path. So when a client send this request to our server:

GET / HTTP/1.0

Then we will respond with our hello message:

HTTP/1.0 200 OK
Content-Type: text/plain

Hello world!

The listener line creates a listener on port 3000 and the 0.0.0.0 address means that we will not bind our service to a specific IP address but respond to requests on all network interfaces. Finally, the axum::serve call starts our web service and takes the routing configuration from the app variable set up earlier.

Notice the two unwrap() calls at the end of those lines: this is a rather sloppy error handling. Both the listener and axum may return error, but for now we simply allow the application to panic in that case. You will be able to test this easily if you start the application twice parallelly: the second one will fail to bind to port 3000 because it is already occupied by the first instance. We will learn more sophisticated ways

The await keyword is also important: this is the way to run an async method to completion, and both TcpListener::bind and axum::serve are async methods.

To make the above code compile, we have to add two use statements at the top of the main.rs file:

use axum::Router;
use axum::routing::get;

Now you can build the application with cargo build from the root directory of the project and run ./target/debug/hello_main to start the application.

To test the application, we will use the curl binary (but you can open http://127.0.0.1:3000/ in a browser too). I prefer to use curl because it will nicely display the full HTTP request and response:

$ curl -v http://127.0.0.1:3000/

*   Trying 127.0.0.1:3000...
* Connected to 127.0.0.1 (127.0.0.1) port 3000 (#0)
> GET / HTTP/1.1
> Host: 127.0.0.1:3000
> User-Agent: curl/7.81.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-type: text/plain; charset=utf-8
< content-length: 13
< date: Sat, 27 Jan 2024 11:10:34 GMT
< 
* Connection #0 to host 127.0.0.1 left intact
Hello, world!

As you can see, this response is in plain text, and I promised a JSON response. Enhance our handler a little to return JSON! We will need the serde and serde_json crates for this. The serde crate makes it possible to serialize Rust structs into various formats and serde_json adds support for the JSON format specifically.

Add these dependencies to hello_main/Cargo.toml:

[dependencies]
axum = "0.7.4"
tokio = { version = "1.35.1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

Now in main.rs we can create our response structure:

#[derive(Serialize)]
struct Response {
    message: &'static str,
}

The Serialize derive macro provides the serialization capabilities for our struct. We can create a new handler method with that:

async fn hello_json() -> (StatusCode, Json<Response>) {
    let response = Response {
        message: "Hello, world!",
    };
    
    (StatusCode::OK, Json(response))
}

This method returns a tuple with two items: the first one will be an HTTP status code, the second one will be something to be formatted as JSON. In the function body we build a response struct with the message Hello, world! and return that as a JSON, paired with a HTTP/200 OK status code. A few use statements to add to our main.rs:

use axum::http::StatusCode;
use axum::{Json, Router};
use axum::routing::get;
use serde::Serialize;

Now replace the basic hello handler with our enchanced hello_json handler:

async fn main() {
    let app = Router::new()
        .route("/", get(hello_json));
        
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

and build our project. The compiler will show warning about the unused hello function, but we can ignore that for now. Finally, start the hello_main binary execute the curl command again:

*   Trying 127.0.0.1:3000...
* Connected to 127.0.0.1 (127.0.0.1) port 3000 (#0)
> GET / HTTP/1.1
> Host: 127.0.0.1:3000
> User-Agent: curl/7.81.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-type: application/json
< content-length: 27
< date: Sat, 27 Jan 2024 11:28:00 GMT
< 
* Connection #0 to host 127.0.0.1 left intact
{"message":"Hello, world!"}

Voila, now our little web service returned a properly formatted JSON response with the Content-Type header set to application/json.

Our first, miniature web service is complete with this, now we can start to explore the crates used here in a little more detail: learn the basics of serialization and deserialization, a learn a bit about async programming and get to know the capabilities of the axum crate.