Production-ready microservice in Rust: 12. OpenApi and SwaggerUI
Posted 2024-01-07 13:00:00 by sanyi ‐ 7 min read
Add OpenApi documentation and a SwaggerUI interface to our API
The previous article introduced OpenTelemetry integration, but I later
realized that I accidentally made the integration mandatory and that is
not always practical. Therefore I changed that part a little: first I
added a new layer with the default formatter (this simply outputs
the tracing events to the console) then made the OpenTelemetry integration
optional. In the commands/serve.rs
file and the start_tokio
function:
let subscriber =
tracing_subscriber::registry().with(LevelFilter::from_level(Level::DEBUG));
let telemetry_layer =
if let Some(otlp_endpoint) = settings.tracing.otlp_endpoint.clone() {
let tracer = init_tracer(&otlp_endpoint)?;
let _meter_provider = init_metrics(&otlp_endpoint);
let _log_provider = init_logs(&otlp_endpoint);
Some(tracing_opentelemetry::layer().with_tracer(tracer))
} else {
None
};
subscriber
.with(telemetry_layer)
.with(fmt::Layer::default())
.init();
Now, the OpenTelemetry layer will be added only when the configuration
specifies the otlp_endpoint
url. You can add that to config.json
or simply export this environment variable before you start the application:
$ export SHELTER__TRACING__OTLP_ENDPOINT="http://localhost:4317"
Let's continue to today's topic: OpenApi documentation. I had to choose between two implementations: utoipa and aide. They both support axum and the aide integration seems to be simpler, but I found utoipa to be more flexible, so I went with utoipa.
First, we have to add the new dependencies to both the entity
and the
shelter_main
crates. For the entity crate's Cargo.toml
:
utoipa = { version = "4.1.0", features = ["axum_extras", "chrono"] }
We need the chrono feature to support the NaiveDate
type in our Dog
entity model.
For the shelter_main crate's Cargo.toml
:
utoipa = { version = "4.1.0", features = ["axum_extras", "chrono"] }
utoipa-swagger-ui = { version = "4.0.0", features = ["axum"] }
The second crate provides the SwaggerUI interface. I had to stick with
the 4.0.0
version because the new 5.0.0
version supports axum 0.7
only and we are on axum 0.6
for now.
The utoipa
crate provides the ToSchema
derive macro to generate the
OpenApi schema for our complex types. We have to add this macro to
every struct
used in REST API requests or responses.
In entity/src/dog.rs
:
use utoipa::ToSchema;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize, ToSchema)]
#[sea_orm(table_name = "dog")]
pub struct Model {
// ...
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, DeriveIntoActiveModel, ToSchema)]
pub struct DogCreateRequest {
// ...
}
In api/request/login.rs
:
#[derive(Deserialize, ToSchema)]
pub struct LoginRequest {
// ...
}
In api/response/dogs.rs
:
#[derive(Serialize, ToSchema)]
pub struct DogCreateResponse {
// ...
}
#[derive(Serialize, ToSchema)]
pub struct DogListResponse {
// ...
}
#[derive(Serialize, ToSchema)]
pub struct DogGetResponse {
// ...
}
In api/response/error.rs
:
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct ErrorResponse {
pub status: Status,
pub message: String,
}
#[derive(Serialize, ToSchema)]
pub enum Status {
#[serde(rename = "success")]
Success,
#[serde(rename = "error")]
Error,
}
In api/response/login.rs
:
#[derive(Serialize, ToSchema)]
pub struct LoginResponse {
// ...
}
Next we have to describe our REST API endpoints using the utoipa::path
macro. For the hello
endpoint:
#[utoipa::path(
get,
path = "/hello",
tag = "hello",
responses(
(status = 200, description = "Hello World", body = String),
),
)]
pub async fn hello(State(state): State<Arc<ApplicationState>>) -> Result<String, StatusCode> {
// ...
}
The first line defines the HTTP verb (get
, post
, put
, delete
, etc.). The path parameter
sets the url of the endpoint. We can use tags to group the endpoints, here we put the hello
endpoint into the hello
group. Finally we provide specifications for the possible responses:
we only have a simple HTTP 200 response here with a string body.
The login
endpoint is a bit more complicated:
#[utoipa::path(
post,
path = "/login",
tag = "login",
request_body = LoginRequest,
responses(
(status = 200, description = "Login success", body = LoginResponse),
(status = 401, description = "Unauthorized", body = ErrorResponse),
),
)]
pub async fn login(
State(state): State<Arc<ApplicationState>>,
CustomJson(payload): CustomJson<LoginRequest>,
) -> Result<Json<LoginResponse>, AppError> {
// ...
}
Now we use the post
method and have a request body. The endpoint expects
a LoginRequest
in the request body so we specify that schema in the
request_body
parameter. We have two alternatives in the responses
section:
a successful HTTP 200 response with a LoginResponse
in the body and
a HTTP 401 response with an ErrorResponse
in the body.
Similarly for the dog create
endpoint:
#[utoipa::path(
post,
path = "/dogs",
tag = "dogs",
request_body = DogCreateRequest,
responses(
(status = 200, description = "Dog create", body = DogCreateResponse),
(status = 401, description = "Missing bearer token", body = ErrorResponse),
),
)]
#[debug_handler]
#[instrument(level = "info", name = "create_dog", skip_all)]
pub async fn create(
Extension(_claims): Extension<TokenClaims>,
State(state): State<Arc<ApplicationState>>,
CustomJson(payload): CustomJson<DogCreateRequest>,
) -> Result<Json<DogCreateResponse>, AppError> {
// ...
}
Here we expect a DogCreateRequest
and return either a DogCreateResponse
or an
ErrorResponse
.
The dog list
endpoint is similar to hello
:
#[utoipa::path(
get,
path = "/dogs",
tag = "dogs",
responses(
(status = 200, description = "Hello World", body = DogListResponse),
),
)]
#[instrument(level = "info", name = "list_dogs", skip_all)]
pub async fn list(
State(state): State<Arc<ApplicationState>>,
) -> Result<Json<DogListResponse>, AppError> {
// ...
}
Finally the dog get
endpoint:
#[utoipa::path(
get,
path = "/dogs/{dogId}",
tag = "dogs",
params(
("dogId" = i32, Path, description = "id of the dog"),
),
responses(
(status = 200, description = "Dog", body = DogGetResponse),
),
)]
#[instrument(level = "info", name = "get_dog", skip_all)]
pub async fn get(
State(state): State<Arc<ApplicationState>>,
Path(dog_id): Path<i32>,
) -> Result<Json<DogGetResponse>, AppError> {
// ...
}
Here we have a path parameter named dogId
with the type i32
.
Now we are ready to create our OpenApi specification. I placed this
in src/api/v1.rs
so we can have different specifications for the
different API versions later. Our first task is to create an OpenApi
struct:
use utoipa::{
openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme},
Modify, OpenApi,
};
#[derive(OpenApi)]
#[openapi(
paths(
),
components(
schemas(
),
),
modifiers(&SecurityAddon),
tags(
(name = "hello", description = "Hello"),
(name = "login", description = "Login"),
(name = "dogs", description = "Dogs"),
),
servers(
(url = "/v1", description = "Local server"),
),
)]
pub struct ApiDoc;
This struct will describe our API thanks to the OpenApi
derive macro.
The paths
section will list all the endpoints. Then we have to add
all referenced structs/schemas to the components.schemas
section.
The tags
section defines the endpoint groups I mentioned earlier,
we have three of them: hello, login and dogs. The servers
section
defines the possible URLs where our endpoint may be tested, this is
required for the SwaggerUI interface's "Try it out" functionality.
Our API is prefixed with /v1
so I specified that path, but you
can reference fully qualified URLs like https://our-great-service.com/v1/
as well.
Finally that modifiers
section: some of our endpoints require JWT
based HTTP bearer token authentication. We can describe that fact with
this snippet:
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.as_mut().unwrap();
components.add_security_scheme(
"api_jwt_token",
SecurityScheme::Http(
HttpBuilder::new()
.scheme(HttpAuthScheme::Bearer)
.bearer_format("JWT")
.build(),
),
)
}
}
and the modifiers
section simply references the SecurityAddon
struct.
Now fill the sections with our endpoints and schemas:
#[derive(OpenApi)]
#[openapi(
paths(
handlers::hello::hello,
handlers::login::login,
handlers::dogs::create,
handlers::dogs::list,
handlers::dogs::get,
),
components(
schemas(
crate::api::request::login::LoginRequest,
crate::api::response::login::LoginResponse,
crate::api::response::error::ErrorResponse,
crate::api::response::error::Status,
crate::api::response::dogs::DogGetResponse,
crate::api::response::dogs::DogListResponse,
crate::api::response::dogs::DogCreateResponse,
entity::dog::Model,
entity::dog::DogCreateRequest,
),
),
...
The final step: add the SwaggerUI and OpenApi endpoint to our Axum router
configuration. In api/mod.rs
:
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
pub fn configure(state: Arc<ApplicationState>) -> Router {
Router::new()
.merge(SwaggerUi::new("/swagger-ui").url(
"/v1/api-docs/openapi.json",
crate::api::v1::ApiDoc::openapi(),
))
.nest("/v1", v1::configure(state))
}
We define that the SwaggerUI will be available at /swagger-ui
and
our OpenApi schema definition a /v1/api-docs/openapi.json
.
Now we are ready to build and start our project again:
$ cargo build
...
$ ./target/debug/shelter_main serve
If everything went well, you can see the SwaggerUI interface on
http://http://127.0.0.1:8080/swagger-ui
.
You can find the sample code on GitHub