OpenAPI Documentation Generation

OpenAPI is an open-source specification for describing RESTful API interface designs. It defines API request and response structures, parameters, return types, error codes, and other details in JSON or YAML format, making communication between client and server more explicit and standardized.

OpenAPI was originally the open-source version of the Swagger specification and has now become an independent project supported by many large enterprises and developers. Using the OpenAPI specification helps development teams collaborate better, reduce communication costs, and improve development efficiency. Additionally, OpenAPI provides developers with tools for automatically generating API documentation, mock data, and test cases, facilitating development and testing work.

Salvo provides OpenAPI integration (modified from utoipa). Salvo elegantly extracts relevant OpenAPI data type information automatically from Handler based on its own characteristics. Salvo also integrates several popular open-source OpenAPI interfaces such as SwaggerUI, Scalar, RapiDoc, and ReDoc.

Since Rust type names can be long and not always suitable for OpenAPI usage, salvo-oapi provides the Namer type, which allows customizing rules to change type names in OpenAPI as needed.

Example Code

main.rs
Cargo.toml
use salvo::oapi::extract::*;
use salvo::prelude::*;

#[endpoint]
async fn hello(name: QueryParam<String, false>) -> String {
    format!("Hello, {}!", name.as_deref().unwrap_or("World"))
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt().init();

    let router = Router::new().push(Router::with_path("hello").get(hello));

    let doc = OpenApi::new("test api", "0.0.1").merge_router(&router);

    let router = router
        .unshift(doc.into_router("/api-doc/openapi.json"))
        .unshift(SwaggerUi::new("/api-doc/openapi.json").into_router("/swagger-ui"));

    let acceptor = TcpListener::new("0.0.0.0:8698").bind().await;
    Server::new(acceptor).serve(router).await;
}

Enter http://localhost:8698/swagger-ui in your browser to see the Swagger UI page.

The OpenAPI integration in Salvo is quite elegant. For the example above, compared to a normal Salvo project, we just did the following steps:

  • Enable the oapi feature in Cargo.toml: salvo = { workspace = true, features = ["oapi"] };

  • Replace #[handler] with #[endpoint];

  • Use name: QueryParam<String, false> to get the value of the query string. When you visit http://localhost/hello?name=chris, the name query string will be parsed. The false in QueryParam<String, false> means that this parameter is optional. If you visit http://localhost/hello, it will not report an error. On the contrary, if it is QueryParam<String, true>, it means that this parameter must be provided, otherwise an error will be returned.

  • Create OpenAPI and the corresponding Router. The merge_router in OpenApi::new("test api", "0.0.1").merge_router(&router) means that this OpenAPI obtains the necessary document information by parsing a certain route and its sub-routes. Some Handlers of routes may not provide information for generating documents, and these routes will be ignored, such as Handlers defined using the #[handler] macro instead of the #[endpoint] macro. That is to say, in actual projects, for reasons such as development progress, you can choose not to generate OpenAPI documents, or partially generate OpenAPI documents. Subsequently, you can gradually increase the number of OpenAPI interfaces generated, and all you need to do is change #[handler] to #[endpoint] and modify the function signature.

Data Extractors

You can import preset common data extractors through use salvo::oapi::extract::*;. The extractor will provide some necessary information to Salvo so that Salvo can generate OpenAPI documents.

  • QueryParam<T, const REQUIRED: bool>: An extractor for extracting data from query strings. QueryParam<T, false> means that this parameter is not required and can be omitted. QueryParam<T, true> means that this parameter is required and cannot be omitted. If it is not provided, an error will be returned;

  • HeaderParam<T, const REQUIRED: bool>: An extractor for extracting data from the request header. HeaderParam<T, false> means that this parameter is not required and can be omitted. HeaderParam<T, true> means that this parameter is required and cannot be omitted. If it is not provided, an error will be returned;

  • CookieParam<T, const REQUIRED: bool>: An extractor for extracting data from the request cookie. CookieParam<T, false> means that this parameter is not required and can be omitted. CookieParam<T, true> means that this parameter is required and cannot be omitted. If it is not provided, an error will be returned;

  • PathParam<T>: An extractor for extracting path parameters from the request URL. If this parameter does not exist, the route matching will not be successful, so there is no case where it can be omitted;

  • FormBody<T>: Extracts information from the submitted form of the request;

  • JsonBody<T>: Extracts information from the JSON-formatted payload submitted by the request;

#[endpoint]

When generating OpenAPI documents, you need to use the #[endpoint] macro instead of the regular #[handler] macro. It is actually an enhanced version of the #[handler] macro.

  • It can obtain the necessary information for generating OpenAPI through the function signature;

  • For information that is not convenient to provide through the signature, you can directly provide it by adding attributes in the #[endpoint] macro. The information provided in this way will be merged with the information obtained through the function signature. If there is a conflict, it will overwrite the information provided by the function signature.

You can use Rust's built-in #[deprecated] attribute to mark a Handler as obsolete. Although the #[deprecated] attribute supports adding information such as the reason for deprecation and the version, OpenAPI does not support it, so this information will be ignored when generating OpenAPI.

The doc comment part in the code will be automatically extracted to generate OpenAPI. The first line is used to generate summary, and the entire comment part will be used to generate description.

/// This is a summary of the operation
///
/// All lines of the doc comment will be included to operation description.
#[endpoint]
fn endpoint() {}

ToSchema

You can use #[derive(ToSchema)] to define data structures:

#[derive(ToSchema)]
struct Pet {
    id: u64,
    name: String,
}

You can use #[salvo(schema(...))] to define optional settings:

  • example = ... can be json!(...). json!(...) will be parsed by serde_json::json! as serde_json::Value.

    #[derive(ToSchema)]
    #[salvo(schema(example = json!({"name": "bob the cat", "id": 0})))]
    struct Pet {
        id: u64,
        name: String,
    }
  • xml(...) can be used to define Xml object properties:

    #[derive(ToSchema)]
    struct Pet {
        id: u64,
        #[salvo(schema(xml(name = "pet_name", prefix = "u")))]
        name: String,
    }

ToParameters

Generate path parameters from the fields of the structure.

This is the #[derive] implementation of the ToParameters trait.

Usually, path parameters need to be defined in #[salvo_oapi::endpoint(...parameters(...))] of the endpoint. However, when using a struct to define parameters, the above steps can be omitted. Nevertheless, if you need to give a description or change the default configuration, then primitive types and String path parameters or [tuple] style path parameters still need to be defined in parameters(...).

You can use Rust's built-in #[deprecated] attribute to mark fields as deprecated, which will be reflected in the generated OpenAPI specification.

The #[deprecated] attribute supports adding extra information such as the reason for deprecation or the version from which it is deprecated, but OpenAPI does not support it. OpenAPI only supports a boolean value to determine whether it is deprecated. Although it is perfectly possible to declare a deprecation with a reason, such as #[deprecated = "There is better way to do this"], this reason will not be presented in the OpenAPI specification.

The comment document on the structure field will be used as the parameter description in the generated OpenAPI specification.

#[derive(salvo_oapi::ToParameters, serde::Deserialize)]
struct Query {
    /// Query todo items by name.
    name: String
}

ToParameters Container Attributes for #[salvo(parameters(...))]

The following attributes can be used in the container attribute #[salvo(parameters(…))] of the structure derived from ToParameters

  • names(...) Defines a comma-separated list of names for the unnamed fields of the structure used as path parameters. Only supported on unnamed structures.
  • style = ... Defines the serialization method for all parameters, specified by ParameterStyle. The default value is based on the parameter_in attribute.
  • default_parameter_in = ... Defines the default position used by the parameters of this field. The value of this position comes from parameter::ParameterIn. If this attribute is not provided, the default is from query.
  • rename_all = ... can be used as an alternative to serde's rename_all. It actually provides the same functionality.

Use names to define a name for a single unnamed parameter.

# use salvo_oapi::ToParameters;

#[derive(ToParameters, serde::Deserialize)]
#[salvo(parameters(names("id")))]
struct Id(u64);

Use names to define names for multiple unnamed parameters.

# use salvo_oapi::ToParameters;

#[derive(ToParameters, serde::Deserialize)]
#[salvo(parameters(names("id", "name")))]
struct IdAndName(u64, String);

ToParameters Field Attributes for #[salvo(parameter(...))]

The following attributes can be used on structure fields #[salvo(parameter(...))]:

  • style = ... Defines how parameters are serialized by ParameterStyle. The default value is based on the parameter_in attribute.

  • parameter_in = ... Use the value from parameter::ParameterIn to define where this field parameter is. If this value is not provided, the default is from query.

  • explode Defines whether to create a new parameter=value pair for each parameter in object or array.

  • allow_reserved Defines whether reserved characters :/?#[]@!$&'()*+,;= are allowed in the parameter value.

  • example = ... can be a method reference or json!(...). The given example will override any examples of the underlying parameter type.

  • value_type = ... can be used to override the default type used by fields in the OpenAPI specification. This is useful when the default type does not correspond to the actual type, for example, when using third-party types not defined in ToSchema or primitive types. The value can be any Rust type that can be serialized to JSON under normal circumstances, or a custom type like _Object_._Object`_ that will be rendered as a generic OpenAPI object.

  • inline If enabled, the definition of this field type must come from ToSchema, and this definition will be inlined.

  • default = ... can be a method reference or json!(...).

  • format = ... can be a variant of the KnownFormat enum, or an open value in string form. By default, the format is inferred from the type of the attribute according to the OpenApi specification.

  • write_only Defines that the attribute is only used for write operations POST,PUT,PATCH and not GET.

  • read_only Defines that the attribute is only used for read operations GET and not POST,PUT,PATCH.

  • nullable Defines whether the attribute can be null (note that this is different from not required).

  • required = ... is used to force the parameter to be required. See rules.

  • rename = ... can be used as an alternative to serde's rename. It actually provides the same functionality.

  • multiple_of = ... is used to define a multiple of the value. The parameter value is considered valid only when the parameter value is divided by the value of this keyword and the result is an integer. The multiple value must be strictly greater than 0.

  • maximum = ... is used to define the upper limit of the value, including the current value.

  • minimum = ... is used to define the lower limit of the value, including the current value.

  • exclusive_maximum = ... is used to define the upper limit of the value, excluding the current value.

  • exclusive_minimum = ... is used to define the lower limit of the value, excluding the current value.

  • max_length = ... is used to define the maximum length of a string type value.

  • min_length = ... is used to define the minimum length of a string type value.

  • pattern = ... is used to define a valid regular expression that the field value must match. The regular expression adopts the ECMA-262 version.

  • max_items = ... can be used to define the maximum number of items allowed in an array type field. The value must be a non-negative integer.

  • min_items = ... can be used to define the minimum number of items allowed in an array type field. The value must be a non-negative integer.

  • with_schema = ... uses a function reference to create a schema instead of the default schema. The function must satisfy the definitionfn() -> Into<RefOr<Schema>>. It does not receive any parameters and must return any value that can be converted to RefOr<Schema>.

  • additional_properties = ... is used to define free-form types for map, such as HashMap and BTreeMap. Free-form types allow any type to be used in map values. Supported formats are additional_properties and additional_properties = true.

Field nullability and required rules

Some of the rules for nullability and requiredness of ToParameters field attributes can also be used for ToSchema field attributes. See rules.

Partial #[serde(...)] attributes support

The ToParameters derive currently supports some serde attributes. These supported attributes will be reflected in the generated OpenAPI documentation. The following attributes are currently supported:

  • rename_all = "..." is supported at the container level.
  • rename = "..." is only supported at the field level.
  • default is supported at the container and field level according to serde attributes.
  • skip_serializing_if = "..." is only supported at the field level.
  • with = ... is only supported at the field level.
  • skip_serializing = "..." is only supported at the field or variant level.
  • skip_deserializing = "..." is only supported at the field or variant level.
  • skip = "..." is only supported at the field level.

Other serde attributes will affect serialization, but will not be reflected in the generated OpenAPI documentation.

Examples

Demonstrates the use of the #[salvo(parameters(...))] container attribute in conjunction with ToParameters on path parameters, and inlines a query field:

use serde::Deserialize;
use salvo_core::prelude::*;
use salvo_oapi::{ToParameters, ToSchema};

#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
enum PetKind {
    Dog,
    Cat,
}

#[derive(Deserialize, ToParameters)]
struct PetQuery {
    /// Name of pet
    name: Option<String>,
    /// Age of pet
    age: Option<i32>,
    /// Kind of pet
    #[salvo(parameter(inline))]
    kind: PetKind
}

#[salvo_oapi::endpoint(
    parameters(PetQuery),
    responses(
        (status_code = 200, description = "success response")
    )
)]
async fn get_pet(query: PetQuery) {
    // ...
}

Use value_type to override the String type with the i64 type.

# use salvo_oapi::ToParameters;

#[derive(ToParameters, serde::Deserialize)]
#[salvo(parameters(default_parameter_in = Query))]
struct Filter {
    #[salvo(parameter(value_type = i64))]
    id: String,
}

Use value_type to override the String type with the Object type. In the OpenAPI specification, the Object type will be displayed as type:object.

# use salvo_oapi::ToParameters;

#[derive(ToParameters, serde::Deserialize)]
#[salvo(parameters(default_parameter_in = Query))]
struct Filter {
    #[salvo(parameter(value_type = Object))]
    id: String,
}

You can also use a generic to override the default type of a field.

# use salvo_oapi::ToParameters;

#[derive(ToParameters, serde::Deserialize)]
#[salvo(parameters(default_parameter_in = Query))]
struct Filter {
    #[salvo(parameter(value_type = Option<String>))]
    id: String
}

You can even use one [Vec] to override another [Vec].

# use salvo_oapi::ToParameters;

#[derive(ToParameters, serde::Deserialize)]
#[salvo(parameters(default_parameter_in = Query))]
struct Filter {
    #[salvo(parameter(value_type = Vec<i32>))]
    id: Vec<String>
}

We can use another ToSchema to override the field type.

# use salvo_oapi::{ToParameters, ToSchema};

#[derive(ToSchema)]
struct Id {
    value: i64,
}

#[derive(ToParameters, serde::Deserialize)]
#[salvo(parameters(default_parameter_in = Query))]
struct Filter {
    #[salvo(parameter(value_type = Id))]
    id: String
}

Example of attribute value validation

#[derive(salvo_oapi::ToParameters, serde::Deserialize)]
struct Item {
    #[salvo(parameter(maximum = 10, minimum = 5, multiple_of = 2.5))]
    id: i32,
    #[salvo(parameter(max_length = 10, min_length = 5, pattern = "[a-z]*"))]
    value: String,
    #[salvo(parameter(max_items = 5, min_items = 1))]
    items: Vec<String>,
}

Use schema_with to manually implement the schema for the field.

# use salvo_oapi::schema::Object;
fn custom_type() -> Object {
    Object::new()
        .schema_type(salvo_oapi::SchemaType::String)
        .format(salvo_oapi::SchemaFormat::Custom(
            "email".to_string(),
        ))
        .description("this is the description")
}

#[derive(salvo_oapi::ToParameters, serde::Deserialize)]
#[salvo(parameters(default_parameter_in = Query))]
struct Query {
    #[salvo(parameter(schema_with = custom_type))]
    email: String,
}
  • rename_all = ...: Supports a syntax similar to serde to define rules for renaming fields. If both #[serde(rename_all = "...")] and #[salvo(schema(rename_all = "..."))] are defined, #[serde(rename_all = "...")] is preferred.

  • symbol = ...: A string literal used to define the name path of the structure on the line in OpenAPI. For example, #[salvo(schema(symbol = "path.to.Pet"))].

  • default: You can use the Default implementation of the structure to fill all fields with default values.

Error Handling

For general applications, we will define a global error type (AppError) and implement Writer or Scribe for AppError so that errors can be sent to the client as web page information.

For OpenAPI, in order to get the necessary error information, we also need to implement EndpointOutRegister for this error:

use salvo::http::{StatusCode, StatusError};
use salvo::oapi::{self, EndpointOutRegister, ToSchema};

impl EndpointOutRegister for Error {
    fn register(components: &mut oapi::Components, operation: &mut oapi::Operation) {
        operation.responses.insert(
            StatusCode::INTERNAL_SERVER_ERROR.as_str(),
            oapi::Response::new("Internal server error").add_content("application/json", StatusError::to_schema(components)),
        );
        operation.responses.insert(
            StatusCode::NOT_FOUND.as_str(),
            oapi::Response::new("Not found").add_content("application/json", StatusError::to_schema(components)),
        );
        operation.responses.insert(
            StatusCode::BAD_REQUEST.as_str(),
            oapi::Response::new("Bad request").add_content("application/json", StatusError::to_schema(components)),
        );
    }
}

This error set defines all possible error messages that the entire web application may return. However, in many cases, our Handler may only contain a few specific error types. In this case, you can use status_codes to filter out the required error type information:

#[endpoint(status_codes(201, 409))]
pub async fn create_todo(new_todo: JsonBody<Todo>) -> Result<StatusCode, Error> {
    Ok(StatusCode::CREATED)
}