Auto-Generating & Validating OpenAPI Docs in Rust: A Streamlined Approach with Utoipa and Schemathesis
- Reading time:
- 11 min
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.
Artikel teilen