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.