This article is still a work in progress
Restful APIs
An API (Application Programming interface) is a contract that defines a way to access the services of an application. There are many forms of APIs. The focus of the article is on web APIs provided over the internet.
REST Representational State Transfer - This is a client-server architecture where there are two communicating parties. The client is responsible for the representation of the data while the server is responsible for producing and processing the data.
Each request in REST carries all information necessary for it to be processed ( It is stateless). The APIs work on resources. The resource could be a blog post, a photo etc. The most common way to represent the properties of the resources is JSON.
A RESTful API is an HTTP-based web service adhering to the principles of REST.
Basics of the HTTP Protocol
Every HTTP request is sent to a unique URI/ URL (uniform resource identifier) such as https://mwendwakavete.co.ke/post.The URL has three mandatory parts:
- The Scheme - It identifies the communication protocol, https in this case.
- The Domain - This is a DNS domain name that identifies the target system.
mwendwakavete.co.ke - The Path - This identifies the exact resource want to interact with.
A sample request:
GET /post HTTP/1.0
Host: mwendwakavete.co.ke
Accept: application/json
GET is an HTTP verb which expresses our intention with the request. We want to GET something from the server.
/post The path of the resource we want to interact with.
HTTP/1.0 - The http protocol to use.
The other lines contain http headers. The headers are key-value pairs that add more metadata to the request.
The Host header identifies the domain we want to communicate with.
The Accept header indicates the content MIME type we want the server to use for the response. This declares that we are expecting to receive json data and not any other type of data.
After making that request to the server, we expect to receive a response. Our request could succeed or fail due to various errors.
A sample response:
HTTP/1.0 200 OK
Content-Type: application/json
{
"post": [
{
"id": 1,
"title": "Post 1",
"content": "Long String"
},
{
"id": 2,
"title": "Post 2",
"content": "Another Post"
}
]
}
In this case our request was successful with a 200 Status Code. The code indicates whether the request was successful or we ran into an error. The line is followed by HTTP response headers with key-value pairs similar to the HTTP request headers. In this case the server responded back with some json data, as requested.
HTTP Verbs
The most common HTTP verbs are GET, POST, PUT, PATCHand DELETE.
- GET - Get information from the system.
- POST - Create something new in the system.
- PUT - Update or overwrite something.
- PATCH - Similar to
PUT: It updates something, but it only changes some attributes of the entity -unlikePUT. - DELETE - Delete something from the server.
Example: Implementation of a basic CRUD operation on blog posts.
Lets assume we have a blog in which we have a table with some blog posts. To add new posts, we have to use the POST HTTP verb to indicate to the server that we want to create a new blog post in the system.
POST /posts HTTP/1.0
Content-Type: application/json
{
"title": "Post 1",
"content": "Once upon a time"
}
Creating the post can either be successful or fail. The server has to send back a response to the client indicating success or failure.
To update a post, we need to send a PUT request. (Note that the ID of the subject post appears in the URL).
PUT /posts/1 HTTP/1.0
Content-Type: application/json
{
"title": "Post 1 updated",
"content": "Far far into the distant future"
}
To get a list of available posts, send a GET request to the general posts url.
GET /posts HTTP/1.0
Accept: application/json
To get information about a specific post, add the ID of the post to the URL.
GET /posts/1 HTTP/1.0
Accept: application/json
To delete a post, send a DELETE request:
DELETE /posts/1 HTTP/1.0
HTTP Response Status Codes
HTTP response messages come with a status code and a short message which gives the clients information about the result of the requested operation.
HTTP response status codes classification:
- range 100..199 - Informational messages
- 200..299 - Success
- 300..399 - Redirection
- 400..499 - Client side errors
- 500..599 - Server side errors
Common Response Codes
Successful:
200 Ok: All went well201 Created: The POSTed entity has been created successfully204 No Content: request processed successfully but there is no content to return
Redirects:
301 Moved Permanently: The URL of the requested resource changed permanently.302 Found: The URL of the requested resource moved temporarily304 Not Modified: Indicates the client can continue to use the cached version of a previous response
Client-side errors:
400 Bad request: The server cannot parse the request401 Unauthorized: The endpoint requires authentication, and the client is not authenticated.403 Forbidden: The client has no sufficient permissions to access the resource404 Not Found: No resource found at the given URL405 Method Not Allowed: The given HTTP verb (e.g DELETE) is not supported on the resource
Server-side errors:
500 Internal Server Error: Generic server-side error status when no other status is applicable.501 Not Implemented: The given HTTP verb is not supported by the server.502 Bad Gateway: An interimproxy or gateway failed to forward the request to the original server.504 Gateway Timeout: The origin server failed to reply in a timely manner
Axum Hello World
Now that we have a basic understanding of Restful Web APIs, let's create a simple one with axum. We are going to create a simple Restful webservice, returning a "hello world" message, formatted as JSON.
This section assumes that you are a Rust programmer and doesn't hold your hand on Rust concepts. Therefore I assume that you already have rust and cargo installed. If all these sound new to you, I recommend you check out The Book first.
I am going to use a workspace for this simple hello world program. First, create a directory to hold the workspace.
mkdir hello_world
cd hello_world
Next, create a Cargo.toml file and add the resolver as 2 under workspace.
[workspace]
resolver = "2"
Create a new package with Cargo
cargo new hello_main
We are going to need the axum crate and the tokio crate as our async runtime. The only features from the tokio crate required by default are macros and rt-multi-thread.
cargo add axum
cargo add tokio --features macros,rt-multi-thread
In our package's src directory, there is already a main function that prints "Hello, world" to the standard output. First, we need to convert our main function function to an asyncronous function and then declare that we are using tokio as our async runtime by adding the attribute macro #[tokio::main] .
#[tokio::main]
async fn main() {
println!("Hello, world");
}
Axum handlers
One of the main components of an axum application is an handler. According to the official docs:
A “handler” is an async function that accepts zero or more “extractors” as arguments and returns something that can be converted into a response.
Handlers are a core part of axum since this is where all the application logic lives. You route between handlers to create an application. As already mentioned, handlers should return something that implements the IntoResponse trait. This trait is implemented by default on many data types. You can read more about it form the official docs about the IntoResponse Trait. Sometimes you might have to implement the trait yourself such as when returning custom erros, which we'll see later.
Let's create a simple handler that returns a string slice, "Hello World". In this case we have specified that we are returning a string slice with a static lifetime.
async fn hello() -> &'static str {
"Hello, world"
}
We could also have generalized and said that we are returning anything that implements IntoResponse, and still return a string slice.
async fn hello() -> impl IntoResponse {
"Hello, world"
}
Routes and application setup
Setup the routing for our web service in the main function and start a simple server. The Router is used to setup which paths go to which service. We want the route / to go have a get service with the logic provided by the hello handler.
use axum::Router;
use axum::routing::get;
#[tokio::main]
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. When a client sents the request to GET / we respond with the hello message.
The listener listens on port 3000. The 0.0.0.0 in the address means that we will not bind our service to a specific IP address but respond to requests on all network interfaces.
The axum::serve starts the web service and takes the routing configuration from the app variable.
To test the basic web app, run the application with cargo run --bin hello_main. Simply running cargo run will also work since we only have one member. I am going to use curl to test the application.
curl -v http://127.0.0.1:3000
As previously mentioned, most Restful APIs work with JSON data. We are therefore going to need a way to respond with JSON data. To serve JSON instead of plain text, we are going to add serde and serde_json as dependencies.
cargo add serde_json
cargo add serde --features=derive
Instead of returning plain text, lets create a struct to return as a response. This could be a blog post, a book etc. I am quite the creative and I'll call this struct Response.
use serde::Serialize;
#[derive(Serialize)]
struct Response {
// A &'static str lives for the entire lifetime of the program. It is part of the binary!
message: &'static str,
}
The Serialize derive macro provides the serialization capabilities for our struct.
Json handler
Create a new handler method to respond with json.
use axum::http::StatusCode;
use axum::Json;
async fn hello_json() ->(StatusCode, Json<Response>) {
let response = Response {
message: "Hello, world!",
};
(StatusCode::OK, Json(response))
}
The method returns a tuple with an HTTP status code and something that will be formatted as JSON.
Add a route for the hello_json function.
let app = Router::new()
.route("/", get(hello))
.route("/hello", get(hello_json));
Opening the url 127.0.0.1:300/hello on the browser should display the return message in JSON format.
Curl command.
curl -v http://127.0.0.1:300/hello
The Content-Type header should now be application/json instead of plain/text
Hello World full code
use axum::{Router, routing::get, Json};
use axum::http::StatusCode;
use serde::Serialize;
// Setup tokio runtime
#[tokio::main]
async fn main() {
// Create routes. Send a get request to /. The reponse comes from the functio hello.
let app = Router::new()
.route("/", get(hello))
.route("/hello", get(hello_json));
async fn hello() -> &'static str {
"Hello World"
}
async fn hello_json() ->(StatusCode, Json<Response>) {
let response = Response {
message: "Hello, world!",
};
(StatusCode::OK, Json(response))
}
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[derive(Serialize)]
struct Response {
// A &'static str lives for the entire lifetime of the program. It is part of the binary!
message: &'static str,
}
Error Handling
Error handling is a key part of any application.So fare, we've only been using the unwrap method that just panics incase of an error.Instead of using unwrap which panics on encountering any error there are other more graceful options. For example, expect() can be used to provide some context to the error.
Rust functions can return a Result type, which is an enum with two variants Ok and Err. The Err variant can hold anything, including non-errors. There is also no standard error type, only an Error trait. This means that one must create their own type.
The anyhow crate can be used which introduces its own Error that can hold arbitrary errors, add context to errors and create errors on the fly from strings.
Error handling in main
A Result return type can be added to the main function and then return an error from it. This will print "Error: " followed by the error's debug representation, then exit with exit code 1.
Anyhow's Error type pretty prints the errors it carries so it can be returned from main.
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let app = Router::new().route("/", get(hello));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
Context can be added by using the anyhow::Context method.
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.context("failed to bind TCP Listener")?;
axum::serve(listener,app).await.context("axum::serve failed")?;
Error handling in handlers
Most errors encountered will be inside error request handlers, and will have to be returned to the caller along with an appropriate HTTP status code.
Lets call a function that can fail.
async fn hello_json() -> (StatusCode, Json<Response>) {
let response = Response {
message: henerate_message().expect("failed to generate message."),
};
(StatusCode::OK, Json(response))
}
fn generate message() -> anyhow::Result<&' static str> {
if rand::random() {
anyhow::bail!("No message for you");
}
Ok("Hello World")
}
anyhow::bail! returns an error on the fly.
Let's add the CatchPanic middleware from the tower-http crate. Add tower-http crate and enable catch-panic feature.
cargo add -F catch-panic tower-http
Then add the middleware.
let app = Router::new(),route("/", get(hello)).layer(tower_http::catch_panic::CatchPanicLayer::new());
With this, we'll get a 500 Internal Server error in case of a panic.
Let's fix the handler so that it handles the error. Axum allows handlers to return a Result but the Error variant must implement IntoResponse so that the error can be converted into a response. Use a newtype to wrap the anyhow error.
struct AppError(anyhow::Error);
impl From<anyhow::Error> for AppError {
fn from(value: anyhow::Error) -> Self {
Self(value)
}
}
Implement IntoResponse where the actual response format of the error is determined.
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
(StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()).into_response()
}
}
Return AppError from the handler.
async fn hello() -> Result<(StatusCode, Json<Response>), AppError> {
let response = Response {
message: generate_message().context("Failed to generate message")?,
};
Ok((StatusCode::OK, Json(response)))
}