Awesome
Hey, today I wanted to share my knowledge on how to write a Rest API in Rust. It may be easier than you think!
We won't showcase database connectivity in this article. Instead, we focused on demonstrating how to generate OpenAPI
specifications and serve a Swagger UI
.
You can find the full code source on github.
Before starting, make sure you have Rust installed.
Let's start by initializing a new project using cargo init
.
cargo init my-rest-api
cd my-rest-api
That should produce the following directory structure:
├── Cargo.toml
└── src
└── main.rs
You can use rustfmt
for formatting. To do so, create a rustfmt.toml
file with the following content:
indent_style = "Block"
max_width = 80
tab_spaces = 2
reorder_imports = false
reorder_modules = false
force_multiline_blocks = true
brace_style = "PreferSameLine"
control_brace_style = "AlwaysSameLine"
I personally use VSCode. Optionally, you can add this configuration in your .vscode/settings.json
:
{
"editor.rulers": [80],
"editor.tabSize": 2,
"editor.detectIndentation": false,
"editor.trimAutoWhitespace": true,
"editor.formatOnSave": true,
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true,
"rust-analyzer.showUnlinkedFileNotification": false,
"rust-analyzer.checkOnSave": true,
"rust-analyzer.check.command": "clippy"
}
Your new directory structure should look like this:
├── .gitignore
├── .vscode
│ └── settings.json
├── Cargo.lock
├── Cargo.toml
├── rustfmt.toml
└── src
└── main.rs
We are going to use ntex as our HTTP framework.<br/>
We can install Rust dependencies by running cargo add
.<br/>
Note that when using ntex
, we have the ability to choose our runtime
.<br/>
To quickly summarize, the runtime
will manage your async|await
pattern.<br/>
If you are familiar with the nodejs runtime
, it's kind of similar in usage.
For this tutorial, we are going to use tokio as it seems to be the more popular choice. Let's add ntex as a dependency:
cargo add ntex --features tokio
Then we are going to update our main.rs
file with the following content:
use ntex::web;
#[web::get("/")]
async fn index() -> &'static str {
"Hello world!"
}
#[ntex::main]
async fn main() -> std::io::Result<()> {
web::server(|| web::App::new().service(index))
.bind(("0.0.0.0", 8080))?
.run()
.await?;
Ok(())
}
We can run our project by using the following command:
cargo run
This command will compile our code and run it.<br/> You should see the following output:
Finished dev [unoptimized + debuginfo] target(s) in 17.38s
Running `target/debug/my-rest-api`
We can test our server using curl:
curl -v localhost:8080
* Trying 127.0.0.1:8080...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.68.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-length: 12
< content-type: text/plain; charset=utf-8
< date: Fri, 26 May 2023 11:43:01 GMT
<
* Connection #0 to host localhost left intact
Hello world!%
Congratulations! You now have your first HTTP server in Rust
!
Now let's create our first REST endpoints
.
Regarding the directory architecture, it's up to personal preference. In ntex
, we use the .service()
method to add new endpoints
. Therefore, I have chosen to create a directory called services
to house my endpoints.
Let's create the directory:
mkdir src/services
touch src/services/mod.rs
Note that by default, Rust
tries to import a mod.rs
file from our directories.
Let's create our default endpoints
inside services/mod.rs
:
use ntex::web;
pub async fn default() -> web::HttpResponse {
web::HttpResponse::NotFound().finish()
}
Now we need to indicate that we want to use this module in our main.rs:
use ntex::web;
mod services;
#[ntex::main]
async fn main() -> std::io::Result<()> {
web::server(|| {
web::App::new()
// Default endpoint for unregisterd endpoints
.default_service(web::route().to(services::default)
)
})
.bind(("0.0.0.0", 8080))?
.run()
.await?;
Ok(())
}
Now, for any unregistered endpoints
, we will have a 404 error.
Before continuing, let's add four dependencies: serde
and serde_json
for JSON serialization, and utoipa
with utoipa-swagger-ui
to have an OpenAPI
swagger.
cargo add serde --features derive
cargo add serde_json utoipa utoipa-swagger-ui
Next, we are going to create our own HttpError
type as helpers. Create a file under src/error.rs
with the following content:
use ntex::web;
use ntex::http;
use utoipa::ToSchema;
use serde::{Serialize, Deserialize};
/// An http error response
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct HttpError {
/// The error message
pub msg: String,
/// The http status code, skipped in serialization
#[serde(skip)]
pub status: http::StatusCode,
}
/// Helper function to display an HttpError
impl std::fmt::Display for HttpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}] {}", self.status, self.msg)
}
}
/// Implement standard error for HttpError
impl std::error::Error for HttpError {}
/// Helper function to convert an HttpError into a ntex::web::HttpResponse
impl web::WebResponseError for HttpError {
fn error_response(&self, _: &web::HttpRequest) -> web::HttpResponse {
web::HttpResponse::build(self.status).json(&self)
}
}
We need to import our error module in our main.rs
let update it:
use ntex::web;
mod error;
mod services;
#[ntex::main]
async fn main() -> std::io::Result<()> {
web::server(|| {
web::App::new()
// Default endpoint for unregisterd endpoints
.default_service(web::route().to(services::default)
)
})
.bind(("0.0.0.0", 8080))?
.run()
.await?;
Ok(())
}
I think we are ready to write some example endpoints
. Let's simulate a todo list and create a new file under src/services/todo.rs
:
use ntex::web;
#[web::get("/todos")]
pub async fn get_todos() -> web::HttpResponse {
web::HttpResponse::Ok().finish()
}
#[web::post("/todos")]
pub async fn create_todo() -> web::HttpResponse {
web::HttpResponse::Created().finish()
}
#[web::get("/todos/{id}")]
pub async fn get_todo() -> web::HttpResponse {
web::HttpResponse::Ok().finish()
}
#[web::put("/todos/{id}")]
pub async fn update_todo() -> web::HttpResponse {
web::HttpResponse::Ok().finish()
}
#[web::delete("/todos/{id}")]
pub async fn delete_todo() -> web::HttpResponse {
web::HttpResponse::Ok().finish()
}
pub fn ntex_config(cfg: &mut web::ServiceConfig) {
cfg.service(get_todos);
cfg.service(create_todo);
cfg.service(get_todo);
cfg.service(update_todo);
cfg.service(delete_todo);
}
We need to update our src/services/mod.rs
to import our todo.rs
:
pub mod todo;
use ntex::web;
pub async fn default() -> web::HttpResponse {
web::HttpResponse::NotFound().finish()
}
In our main.rs
:
use ntex::web;
mod error;
mod services;
#[ntex::main]
async fn main() -> std::io::Result<()> {
web::server(|| {
web::App::new()
// Register todo endpoints
.configure(services::todo::ntex_config)
// Default endpoint for unregisterd endpoints
.default_service(web::route().to(services::default))
})
.bind(("0.0.0.0", 8080))?
.run()
.await?;
Ok(())
}
Let's create some data structure for our Todo
.
We are going to create a new directory src/models
with his mod.rs
and a todo.rs
mkdir src/models
touch src/models/mod.rs
touch src/models/todo.rs
In our src/models/mod.rs
we are going to import todo.rs
:
pub mod todo;
And inside src/models/todo.rs
, we are going to add some data structure
:
use utoipa::ToSchema;
use serde::{Serialize, Deserialize};
/// Todo model
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct Todo {
/// The todo id
pub id: i32,
/// The todo title
pub title: String,
/// The todo completed status
pub completed: bool,
}
/// Partial Todo model
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct TodoPartial {
/// The todo title
pub title: String,
}
You may notice that we use the serde
and utoipa
derive macros to enable JSON
serialization and conversion to OpenAPI Schema
.
Don't forget to update your main.rs
to import our models
:
use ntex::web;
mod error;
mod models;
mod services;
#[ntex::main]
async fn main() -> std::io::Result<()> {
web::server(|| {
web::App::new()
// Register todo endpoints
.configure(services::todo::ntex_config)
// Default endpoint for unregisterd endpoints
.default_service(web::route().to(services::default))
})
.bind(("0.0.0.0", 8080))?
.run()
.await?;
Ok(())
}
With the models in place, we can now generate type-safe endpoints with their documentation. Let's update our endpoints
inside src/services/todo.rs
:
use ntex::web;
use crate::models::todo::TodoPartial;
/// List all todos
#[utoipa::path(
get,
path = "/todos",
responses(
(status = 200, description = "List of Todo", body = [Todo]),
),
)]
#[web::get("/todos")]
pub async fn get_todos() -> web::HttpResponse {
web::HttpResponse::Ok().finish()
}
/// Create a new todo
#[utoipa::path(
post,
path = "/todos",
request_body = TodoPartial,
responses(
(status = 201, description = "Todo created", body = Todo),
),
)]
#[web::post("/todos")]
pub async fn create_todo(
_todo: web::types::Json<TodoPartial>,
) -> web::HttpResponse {
web::HttpResponse::Created().finish()
}
/// Get a todo by id
#[utoipa::path(
get,
path = "/todos/{id}",
responses(
(status = 200, description = "Todo found", body = Todo),
(status = 404, description = "Todo not found", body = HttpError),
),
)]
#[web::get("/todos/{id}")]
pub async fn get_todo() -> web::HttpResponse {
web::HttpResponse::Ok().finish()
}
/// Update a todo by id
#[utoipa::path(
put,
path = "/todos/{id}",
request_body = TodoPartial,
responses(
(status = 200, description = "Todo updated", body = Todo),
(status = 404, description = "Todo not found", body = HttpError),
),
)]
#[web::put("/todos/{id}")]
pub async fn update_todo() -> web::HttpResponse {
web::HttpResponse::Ok().finish()
}
/// Delete a todo by id
#[utoipa::path(
delete,
path = "/todos/{id}",
responses(
(status = 200, description = "Todo deleted", body = Todo),
(status = 404, description = "Todo not found", body = HttpError),
),
)]
#[web::delete("/todos/{id}")]
pub async fn delete_todo() -> web::HttpResponse {
web::HttpResponse::Ok().finish()
}
pub fn ntex_config(cfg: &mut web::ServiceConfig) {
cfg.service(get_todos);
cfg.service(create_todo);
cfg.service(get_todo);
cfg.service(update_todo);
cfg.service(delete_todo);
}
With utoipa, we will be able to serve our Swagger documentation.
Let's create a new file under src/services/openapi.rs
:
use std::sync::Arc;
use ntex::web;
use ntex::http;
use ntex::util::Bytes;
use utoipa::OpenApi;
use crate::error::HttpError;
use crate::models::todo::{Todo, TodoPartial};
use super::todo;
/// Main structure to generate OpenAPI documentation
#[derive(OpenApi)]
#[openapi(
paths(
todo::get_todos,
todo::create_todo,
todo::get_todo,
todo::update_todo,
todo::delete_todo,
),
components(schemas(Todo, TodoPartial, HttpError))
)]
pub(crate) struct ApiDoc;
#[web::get("/{tail}*")]
async fn get_swagger(
tail: web::types::Path<String>,
openapi_conf: web::types::State<Arc<utoipa_swagger_ui::Config<'static>>>,
) -> Result<web::HttpResponse, HttpError> {
if tail.as_ref() == "swagger.json" {
let spec = ApiDoc::openapi().to_json().map_err(|err| HttpError {
status: http::StatusCode::INTERNAL_SERVER_ERROR,
msg: format!("Error generating OpenAPI spec: {}", err),
})?;
return Ok(
web::HttpResponse::Ok()
.content_type("application/json")
.body(spec),
);
}
let conf = openapi_conf.as_ref().clone();
match utoipa_swagger_ui::serve(&tail, conf.into()).map_err(|err| {
HttpError {
msg: format!("Error serving Swagger UI: {}", err),
status: http::StatusCode::INTERNAL_SERVER_ERROR,
}
})? {
None => Err(HttpError {
status: http::StatusCode::NOT_FOUND,
msg: format!("path not found: {}", tail),
}),
Some(file) => Ok({
let bytes = Bytes::from(file.bytes.to_vec());
web::HttpResponse::Ok()
.content_type(file.content_type)
.body(bytes)
}),
}
}
pub fn ntex_config(config: &mut web::ServiceConfig) {
let swagger_config = Arc::new(
utoipa_swagger_ui::Config::new(["/explorer/swagger.json"])
.use_base_layout(),
);
config.service(
web::scope("/explorer/")
.state(swagger_config)
.service(get_swagger),
);
}
Don't forget to update src/services/mod.rs
to import src/services/openapi.rs
:
pub mod todo;
pub mod openapi;
use ntex::web;
pub async fn default() -> web::HttpResponse {
web::HttpResponse::NotFound().finish()
}
Then we can update our main.rs
to register our explorer endpoints:
use ntex::web;
mod error;
mod models;
mod services;
#[ntex::main]
async fn main() -> std::io::Result<()> {
web::server(|| {
web::App::new()
// Register swagger endpoints
.configure(services::openapi::ntex_config)
// Register todo endpoints
.configure(services::todo::ntex_config)
// Default endpoint for unregisterd endpoints
.default_service(web::route().to(services::default))
})
.bind(("0.0.0.0", 8080))?
.run()
.await?;
Ok(())
}
We are good to go. Let's run our server:
cargo run
Then we should be able to access to our explorer on http://localhost:8080/explorer/
I Hope you will try to write your next REST API in Rust !
Don't forget to take a look at the dependencies documentation:
Bonus
Create a production docker image !
Add a Dockerfile
in your project directory with the following content:
# Builder
FROM rust:1.69.0-alpine3.17 as builder
WORKDIR /app
## Install build dependencies
RUN apk add alpine-sdk musl-dev build-base upx
## Copy source code
COPY Cargo.toml Cargo.lock ./
COPY src ./src
## Build release binary
RUN cargo build --release --target x86_64-unknown-linux-musl
## Pack release binary with UPX (optional)
RUN upx --best --lzma /app/target/x86_64-unknown-linux-musl/release/my-rest-api
# Runtime
FROM scratch
## Copy release binary from builder
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/my-rest-api /app
ENTRYPOINT ["/app"]
Optionally you can add this release
profile in your Cargo.toml
:
[profile.release]
opt-level = "z"
codegen-units = 1
strip = true
lto = true
This will optimise the release binary to be as small as possible. Additionally with upx we can create really small docker image !
Build your image:
docker build -t my-rest-api:0.0.1 -f Dockerfile .
If you want to see a more real world usecase i invite you to take a look at my opensource project Nanocl. That try to simplify the development and deployment of micro services, with containers or virtual machines !
Happy coding !