This template uses the utoipa crate to generate swagger documentation for the microservice's API at compile time. When the application is started, you can access the documentation at http://localhost:8080/swagger-ui/index.html
Documenting DTOs can be done in two stages: deriving ToSchema
on the DTO and adding it to the components
list on OpenApiSchemas
.
ToSchema
is a trait used by utoipa to read the fields in a DTO into an OpenAPI schema that can be used in the swagger UI.
For request DTOs, nothing needs to be done beyond adding the derive macro to the struct. For response DTOs, it is recommended
to add sample value annotations to the fields so the response looks nice in the swagger UI.
Here's how that might look using the DTO examples in the architecture documentation:
// in dto.rs
// Note the ToSchema derivation here
#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PlayerCreateRequest {
pub full_name: String,
pub username: String,
}
// Note the ToSchema derivation here
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PlayerCreateResponse {
// This annotation provides the value for this field that will
// show up in an example response on Swagger
#[schema(example = 3)]
pub new_player_id: i32,
}
Once ToSchema
is implemented on your new DTOs, you can attach the schema to utoipa by adding it to the list of schemas
defined on the OpenApiSchemas
empty struct at the top of dto.rs
:
// in dto.rs
#[derive(OpenApi)]
#[openapi(components(
schemas(
// Add the schemas here
PlayerCreateRequest,
PlayerCreateResponse,
// ...other schema definitions
),
// ...other OpenAPI information
))]
pub struct OpenApiSchemas;
// ...dto definitions
API endpoints are documented using schema information defined on DTOs and canned responses,
if applicable. Each API has its own OpenAPI struct that then gets composed into the main OpenAPI definition in the
api::swagger_main
module.
OpenAPI information can be added to a request logic function
with the utoipa::path
annotation. It includes information on the API group a route belongs to, request and response
DTO information, the HTTP verb used, and more. A full description of all the options on the annotation can be found
in the utoipa documentation.
With that information, let's see how we would document the "player create" endpoint defined in the above link:
Code example for documenting an endpoint
// in api/player.rs
// We define a constant at the top of the file so we can group all of this API's endpoints together in Swagger
// and rename the group all at once if need be
pub const PLAYER_API_GROUP: &str = "Players";
// ...router definition
// This path definition states the following:
// - You can hit the route with a POST to /players
// - It's part of the PLAYER_API_GROUP
// - The request body is the PlayerCreateRequest schema defined in dto.rs
// - The endpoint will respond in the following ways:
// - With a 201 CREATED, containing the PlayerCreateResponse schema
// - With a 400 BAD REQUEST, using the 400 validation error canned response
// - With a 409 CONFLICT, containing a BasicError schema with the error code "username_in_use" if the
// username is taken
// - With a 500 INTERNAL SERVER ERROR, using the 500 error canned response
//
// The doc comment on this endpoint will get rendered as the description in the swagger UI
//
/// Create a new player in the game
#[utoipa::path(
post,
path = "/players",
tag = PLAYER_API_GROUP,
request_body = PlayerCreateRequest,
responses(
(status = 201, description = "Player successfully created", body = PlayerCreateResponse),
(status = 400, response = dto::err_resps::BasicError400Validation),
(
status = 409,
description = "Username is already taken",
body = BasicError,
example = json!({
"error_code": "username_in_use",
"error_description": "The given username is already taken by another player.",
"extra_info": null,
}),
),
(status = 500, response = dto::err_resps::BasicError500),
),
)]
async fn create_player(
// ...parameters
) -> Result<(StatusCode, Json(dto::PlayerCreateResponse)), ErrorResponse> {
// ...route logic implementation
}
Each router under the api
module has its own set of OpenAPI definitions. The routes under that API get attached
to the definitions under that router so they can be joined to the main OpenAPI spec for the microservice.
As long as your route logic functions are decorated with utoipa::path
, they can be added to the OpenAPI schema
for the given API group via the name of the route logic function:
// in api/player.rs
// Function names for route logic functions go under the "paths" list in this annotation
#[derive(OpenApi)]
#[openapi(paths(
create_player,
))]
pub struct PlayersApi;
// ...rest of the API definition
Once you have the OpenAPI spec defined for an API group, it can then be merged into the main swagger spec defined
in api/swagger_main.rs
. The main OpenAPI spec containing the title and description of the overall API can be edited in
that file too.
Here's how you can add a new API group to the main OpenAPI schema:
// in api/swagger_main.rs
// ...main OpenAPI definition
// This function is already defined
pub fn build_documentation() -> SwaggerUi {
let mut api_docs = TodoApi::openapi();
api_docs.merge(dto::OpenApiSchemas::openapi());
// The OpenAPI definition for your API group gets merged with the .merge() function:
api_docs.merge(super::player::PlayersApi::openapi());
// ...rest of the swagger UI setup
}
In some cases, the same common error response may be returned across multiple different API endpoints. Rather than needing
to redefine the entire response every time that response is used, you can define a canned error response in the dto::err_resps
module. For these, rather than deriving ToSchema
, you derive ToResponse
. In doing so you include a response description
and example value that can be reused across multiple API endpoints. The response is then added to OpenApiSchemas
like a DTO.
Note that a canned response must contain a type implementing ToSchema
which describes the response body before ToResponse
can be derived.
Here's how you might define one:
// in dto.rs
// ...dto definitions
// This submodule already exists, no need to redefine it
pub mod err_resps {
// We need to derive ToResponse on this type to make it a canned response
// The type that derives this trait must contain a schema, which is the schema used
// for the canned response body.
//
// In the response annotation, we describe the canned response and provide an example response body
#[derive(ToResponse)]
#[response(
description = "Conflicting data already exists in the system",
example = json!({
"error_code": "conflicting_data",
"error_description": "Other data already present in the system conflicts with the new data.",
"extra_info": null
})
)]
pub struct BasicError409(BasicError);
// ...other canned responses
}
Once the canned response is defined, we can add it to OpenApiSchemas
just like DTO definitions:
// in dto.rs
#[derive(OpenApi)]
#[openapi(components(
// ...other OpenAPI data
responses(
// Add the canned response here
err_resps::BasicError409,
// ...other canned responses
),
))]
pub struct OpenApiSchemas;