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

Tags:
rust microservice dog-shelter openapi swagger