Auto-Generating & Validating OpenAPI Docs in Rust: A Streamlined Approach with Utoipa and Schemathesis

Learn how to automatically generate and validate OpenAPI docs to ensure accuracy and reliability throughout the lifecycle of your API. Discover a real-world example and integrate powerful tools like Utoipa and Schemathesis into your Rust projects and CI pipelines.

Reading time:
11 min
https://www.freepik.com/free-vector/application-programming-interface-concept-illustration_25625375.htm

The OpenAPI specification has become the industry standard for defining APIs. It is clear that ensuring accurate and up-to-date API documentation is essential for both users and developers. However, manually creating and updating documentation for a constantly evolving API can be tedious and error-prone. In this article we will examine how Utoipa and Schemathesis can be used to generate and validate an API definition directly from Rust code, effectively addressing these challenges.

To illustrate the capabilities of these tools and how they can be seamlessly integrated into your Rust projects and CI pipelines, we will provide examples based on Identeco’s Credential Check Service. The source code for this article can be found in our public Github Repository and provides a minimal working example for you to play around with these concepts and explore them in more detail. By the end of this article, you’ll have a better understanding of how to streamline your API documentation process and ensure that your API remains accurate and reliable throughout its development lifecycle.

OpenAPI Tools

There is a wide array of tools built around the OpenAPI Specification in various languages, each with different objectives. In this article, we will focus on automatically generating an OpenAPI file from Rust code using Utoipa, and testing the generated file using Schemathesis. You may wish to visit openapi.tools to explore alternative or complementary tools for your project. An honorable mention as an auto-generator alternative to Utoipa would be okapi, which unfortunately only supports the Rocket web framework.

Defining an API with Utoipa: An example with Actix

The examples in this article show the usage of Utoipa at Identeco and consists of simplified code snippets from our Credential Check Service. In particular, we will look at the /check_credentials endpoint, which accepts HTTP POST requests containing a JSON body. Although the Credential Checker server is built using the Actix web framework, it’s important to note that Utoipa is framework-agnostic and provides several examples and extras for other popular web frameworks as well.

General Information

The starting point for creating our API definition is an empty struct called ApiDocs. We derive the utoipa::OpenApi macro, which allows us to define general information about the API. The macro structure follows the OpenAPI 3.0 specification and provides helpful error messages like “unexpected attribute, expected any of: handlers, components, modifiers, security, tags, external_docs, servers” in case you need some guidance. If you’re not a fan of overusing macros like this, you’ll be happy to read that you can also use a functional builder approach at runtime. For simplicity, we will focus on the derived macros in this article.

use utoipa::OpenApi;

#[derive(OpenApi)]
#[openapi(
    info(
        title = "Credential Check API definition",
        description = "Enables clients to anonymously check whether their credentials have been involved in a known data breach.",
        contact(
            name = "Identeco",
            email = "contact@identeco.de",
            url = "https://identeco.de"
        ),
        version = "1.0.2"
    ),
    servers(...),
    ...
)]
pub struct ApiDocs;

impl ApiDocs {
    pub fn generate() -> String {
        // Make sure the `"yaml"` feature is enabled in your `Cargo.toml`
        ApiDocs::openapi().to_yaml().unwrap()
    }
}

Request and Response Schemas

Special cryptography is required to allow for the secure and anonymous checking of login data involving passwords. In our simplified example a user has to provide a short prefix of the hashed email along with the treated credentials. Since we care about our user’s privacy, we decide that we want to preserve the k-anonymity of their credentials by limiting the length of the provided prefix to a maximum of 6 characters. To model our request type in code, we start by deriving the utoipa::ToSchema macro. This lets us re-use Rusts doc comments to generate the descriptions for our types and fields, while also giving more fine grained access to OpenAPI attributes with the #[schema(...)] macro.

use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

/// The request body for the `check_credentials` endpoint.
#[derive(ToSchema, Debug, Serialize, Deserialize)]
struct CcRequest {
    /// Prefix of the hashed email
    #[schema(format = "base64", example = "cri4", min_length = 4, max_length = 6)]
    pub prefix: String,

    /// Cryptographically treated email-password combination
    #[schema(format = "base64", example = "02a8902230d79486d10ec6eb6")]
    pub credentials: String,
}

/// The response body for the `check_credentials` endpoint.
#[derive(ToSchema, Debug, Serialize, Deserialize)]
struct CcResponse {
    // ...
}

Specifying the format and constraints, as well as providing examples is not only good for documentation purposes, but will be particularly useful for validating our API later with other OpenAPI tools.

Finally we have to register the schemas to our ApiDocs struct:

#[derive(OpenApi)]
#[openapi(
    ...
    components(schemas(CcRequest, CcResponse)),
)]
pub struct ApiDocs;

Annotating Routes

Next, we need to define the routes for our API. This is done using the #[utoipa::path] macro above each function that serves as an endpoint for your web framework. Note that our example route uses Actix's post macro to define the endpoint as an HTTP POST method. Utoipa is compatible with these macros if the actix_extras feature is enabled; however, if there are no plugins for your chosen web framework, this can also be done by manually providing the post and path attributes within the #[utoipa::path] macro. As seen with the ToSchema attribute for our request type, Rust’s doc comments will be re-purposed to generate a description for our endpoint.

use actix_web::{http::StatusCode, post, web::Json, ResponseError};

/// Checks whether an email-password combination is known to been involved in a data breach.
#[utoipa::path(
    request_body = CcRequest,
    responses((
        status = OK,
        body = CcResponse
    ),(
        status = BAD_REQUEST,
        description = "The request body contained ill formatted values",
        body = String,
        examples(
            ("TooLong" = (value = 
                json!(ApiError::PrefixTooLong { len: 7 }.to_string()))
            ),
        ),
    )),
)]
#[post("/check_credentials")]
async fn check_credentials(
    body: Json<CcRequest>
) -> Result<Json<CcResponse>, ApiError> {
    // ...
}

First, we map the request and response schemas to the check_credentials endpoint. In the future, these may be automatically derived. In addition to a response in case of an HTTP OK status code, we also add examples for each possible error response. Here, ApiError encapsulates all the errors that are part of the public API in one place:

#[derive(Debug, thiserror::Error)]
enum ApiError {
    #[error("Prefix has a length of {len}, but the maximum is 6")]
    PrefixTooLong { len: usize },
}

/// Used in actix web to convert to an HTTP response.
impl ResponseError for ApiError {
    fn status_code(&self) -> StatusCode {
        match *self {
            ApiError::PrefixTooLong { .. } => StatusCode::BAD_REQUEST,
        }
    }
    fn error_response(&self) -> HttpResponse {
        HttpResponse::build(self.status_code())
            .content_type("text/plain")
            .body(self.to_string())
    }
}

We use thiserror to to simplify the definition of our error type and implement Actix’s ResponseError to return the error as http response from our endpoint handler. Thanks to the json! attribute in the response example, we can reuse the error messages for a particular error instead of having to manually write it for each route it’s used in. This adds another layer of verification, as our API documentation will always be in sync with the actual error message.

Similar to the schemas, we also add the routes to our ApiDocs:

#[derive(OpenApi)]
#[openapi(
    ...
    paths(check_credentials)
)]
pub struct ApiDocs;

Custom Security Schema

As with the OpenAPI specification, the SecurityScheme attribute is used to describe the authentication methods used by an API. While you can directly use pre-defined security scheme like OAuth or HttpAuth for your API, custom authentication can also be declared with Utoipa, but must be done so at runtime via the Modify trait as follows:

use utoipa::{
    openapi::security::{ApiKey, ApiKeyValue, SecurityScheme},
    Modify, OpenApi,
};

/// Custom security schemas for the API.
struct SecuritySchemas;

impl Modify for SecuritySchemas {
    fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
        let components = openapi.components.as_mut().unwrap();
        let value = ApiKeyValue::with_description(
            "My-Api-Key", 
            "Custom authentication header"
        );
        let scheme = SecurityScheme::ApiKey(ApiKey::Header(value));
        components.add_security_scheme("My-Api-Key", scheme);
    }
}

#[derive(OpenApi)]
#[openapi(
    ...
    security(("My-Api-Key" = [])),
    modifiers(&SecuritySchemas),
)]
pub struct ApiDocs;

And we’re done defining our API! 🎉

Serving the Documentation as a Swagger UI

You may want to go the extra mile and allow users to view your beautiful API definition right on your server. For this, utoipa_swagger_ui comes in handy as an easy plug-in for various web frameworks.

Creating and Validating API Documentation

Now that we have our API defined in code, what’s next? Well, we still need to take advantage of the work we’ve put in by generating an openapi.yml file and automatically validating it in our CI pipeline.

File Generation

We can automatically generate an OpenAPI file using a small helper binary like src/bin/gen_api.rs:

use identeco_utoipa_example::ApiDocs;

fn main() {
    let content = ApiDocs::generate();
    std::fs::write("openapi.yml", content).unwrap();
}

With a simple command, cargo run --bin gen_api (or any name you’ve chosen for your binary), we can generate an up-to-date openapi.yml file for our server. No more writing the same documentation twice in different formats.

However, there may be situations where a developer modifies the server, forgets to generate the file, or worse, does not realize that their changes break the API.

Keeping the OpenAPI file up to date

In order to ensure that the generated openapi.yml file in the repository is kept up-to-date whenever there are changes to the code, we can add a test to the CI pipeline. This test requires developers to run and commit the build step before their changes can be merged. As a result, the file stays up-to-date, and any overlooked, breakable changes to the API (such as renaming a variable in a request type) can be easily caught by reviewers.

You can integrate this test as a separate job in your CI, or implement it in Rust to run alongside cargo test as follows:

#[test]
fn generated_docs_are_up_to_date() {
    let path = "openapi.yml";
    let current = std::fs::read_to_string(path)
        .expect("The current openapi file must exist");
    let newest = ApiDocs::generate();

    assert_eq!(
        newest, current,
        "
============================================================
NOTE: The generated `{path}` file is not up to date.
Please run `cargo run --bin gen_api` and commit the changes.
============================================================
"
    )
}

This is where the pre-commit hooks or a custom build script might come in handy to perform the build step automatically.

Validation with Schemathesis

Generating documentation from code is a helpful practice, but it doesn’t guarantee correctness. Despite already eliminating a lot of potential errors by leveraging Rusts strong type system, we might end up mapping the wrong request body type to an endpoint, or provide invalid examples values. To avoid such problems, we need to validate the actual responses from a server against the documentation. Luckily, there are many different tools for testing an OpenAPI file against a server in this way. We will look at Schemathesis in particular, but as always, feel free to look at the alternatives.

Schemathesis generates test cases based on the OpenAPI schema, using property-based testing and various input generation strategies. This is where our fine-grained examples and format restrictions from earlier make it easier to generate valid and invalid test cases. With the help of Schemathesis, Identeco’s CI pipeline managed to catch a crucial error before it was merged into production. The error occurred during a huge refactor, after the response type of the check_credentials endpoint was changed and the OpenAPI annotations went out of sync. That way, it could have gone unnoticed that the response was serialized differently, in turn potentially breaking the API for customers and requiring an immediate hotfix. Schemathesis manage to caught this error early.

CI Pipeline Integration

To run Schemathesis in your pipeline, you can add a test job that starts a dummy server and runs the tests against it. It’s important that the setup for your dummy server is the same as that used in production. For Identeco’s Credential Checker, we simply mock the database connections in memory and add a valid API key for testing. A sample Gitlab CI pipeline job might look like this:

test-openapi-spec:
  image: rust:latest
  stage: test
  before_script:
    - apt-get update && apt-get install -y wait-for-it python3 python3-pip
    - pip3 install schemathesis
  script:
    # Build the binary in the foreground
    - cargo build --bin server
    # Start the server (or a dummy) in the background
    - cargo run --bin server &
    # Wait for the server to be ready
    - wait-for-it localhost:8080 --timeout=30
    # Run the `schemathesis` tests
    - >
      st run openapi.yml \
        --checks all \
        --data-generation-method all \
        --validate-schema true \
        --base-url http://localhost:8080 \
        -H "My-Api-Key: 123456"      
    # Kill the server to free the address
    - kill %%

Conclusion

In this article, we’ve explored how Utoipa and Schemathesis can be used to automatically generate and validate API documentation directly from Rust code. By walking through a real-world example from Identeco’s Credential Check Service, we demonstrated how these tools can be seamlessly integrated into Rust projects and CI pipelines.

Using Utoipa, we showcased how to define an API, annotate routes, and use custom security schemas. Additionally, we learned how to generate and validate an OpenAPI file, ensuring it stays up-to-date and error-free.

With Schemathesis, we demonstrated the importance of validating our API documentation against real-world examples, catching errors that could have otherwise gone unnoticed.

By leveraging these powerful tools, you can now streamline your API documentation process, improve reliability, and reduce the likelihood of errors slipping into production. This ultimately leads to a better experience for both developers and users of your API, while saving you valuable time and effort in maintaining accurate and up-to-date documentation.

Contact an Expert

Do you have any further questions or need specific help? Write us a message or arrange a meeting directly.

Show more

Get to the blog