Generating and exposing machine- and human-readable documentation for RESTful APIs is a must, and those built with Spin are no exception. The OpenAPI Specification (OAS) is the de-facto standard when it comes to documenting RESTful APIs. In this article we will explore how one could generate OAS-compliant documentation from within Spin applications written in Rust.
Before diving straight into a practical example, we’ll do a quick refresher on what OAS actually is.
What is the OpenAPI Specification (OAS)
The OpenAPI Specification (OAS) serves as a universal, language-agnostic standard for describing the structure and features of RESTful APIs. By establishing a clear, machine- and human-readable contract, OAS allows developers, API consumers, and tools to understand available endpoints, request and response schemas, authentication methods, and more.
Leveraging the OAS description, teams can also provide interactive documentation UIs, generate client SDKs for various programming languages, server mocks for rapid prototyping, and even automated tests for end-to-end validation.
The OpenAPI description not only streamlines onboarding for new developers but also ensures that your API remains consistently documented and discoverable throughout its lifecycle, making OAS an indispensable tool for modern API development.
A Practical Guide To OpenAPI Documentation
Prerequisites and Setup
To follow along with the instructions and snippets shown as part of this article, you must have the following things installed on your development machine:
- Spin CLI version
3.3.1
or later (see installation instructions) - Rust version
1.86.0
or later including thewasm32-wasip1
target (see Rust and target installation instructions) - Git CLI (see installation instructions)
- An Editor
We will use an API that implements simple “to-do” list functionality as a starting point. You can find the source code on GitHub at https://github.com/ThorstenHans/spin-todo-api.
Clone the repository and build the Spin application:
# Clone the Spin application from GitHub
git clone git@github.com:ThorstenHans/spin-todo-api.git
# Move into the application directory
cd spin-todo-api
# Build the Spin application
spin build
If your development machine has all the necessary tools and is set up correctly, you should see output along the lines of this:
Building component spin-todo-api with `cargo build --target wasm32-wasip1 --release`
# ...
# ...
Finished building all Spin components
Exploring the ToDo-API
Now that we’ve successfully built our application, let’s take a look at it’s functionality. The pre-coded ToDo-API is a RESTful HTTP API that exposes the following endpoints:
GET /api/todos
- Retrieve a list of all ToDo-itemsGET /api/todos/:id
- Retrieve a particular ToDo-item using its identifierPOST /api/todos
- Create a new ToDo-itemPOST /api/todos/:id/toggle
- Toggle a ToDo-item using its identifierDELETE /api/todos/:id
- Delete a ToDo-item using its identifier
ToDo-items are stored and retrieved from a key-value store (see src/domain/todo.rs
) which is automatically managed by the runtime (Spin, Fermyon Wasm Functions or Fermyon Cloud) on your behalf.
On top of the default dependencies, the following crates have been added to the ToDo-API:
uuid (features: v4, serde)
- Used to generate identifiers for ToDo-itemsserde (features: derive)
- Used to serialize and deserialize Rust structsserde_json
- JSON support forserde
url
- Which we’ll use later to parse URLs
Meet the utoipa
Crate
The utoipa
crate is designed to simplify the process of generating OpenAPI documentation directly from within your codebase. By leveraging macros, utoipa
automatically produces a comprehensive OAS description according to the OAS specification.
This allows us to keep the documentation of our RESTful API in sync with the actual implementation, while requiring minimal effort.
To add utoipa
with its optional uuid
feature as a dependency use the cargo add
command as shown below:
# Run the following command from the spin-todo-api directory
# Add utoipa with uuid feature as a dependency
cargo add utoipa -F uuid
With the utoipa
crate being added as a dependency to the ToDo-API (see Cargo.toml
), we can now start documenting our API. Generally speaking, there are three different documentation assets we have to provide:
- General API documentation
- API request- and response-model documentation
- API endpoint documentation
The following sections will guide you through each documentation asset, and provide an example of how to update the implementation.
General API Documentation
You can think of “General API documentation” as fundamental metadata about your RESTful API (such as contact information, license, tags, etc.). We use the #[openapi]
macro on a custom Rust struct to specify all static information.
utoipa
also supports customizing the “General API documentation” at runtime by either adding custom modifiers (for reusable, modular customizations) or by leveraging the APIs provided by utoipa
(for simpler, direct modifications).
We will start with defining all static information using the #[openapi]
macro, but will add dynamic customization later in this article. First, we have to create a new struct
in src/handlers/docs.rs
, which we will decorate using the utoipa::openapi
macro. Additionally, we’ll derive from utoipa::OpenApi
:
use utoipa::{OpenApi};
#[derive(OpenApi)]
#[openapi(
info(
title = "ToDo API",
description = "An awesome ToDo API",
terms_of_service = include_str!("../../fake_terms_of_service.txt"),
contact(
name = "Fermyon Engineering",
email = "engineering@fermyon.com",
url = "https://www.fermyon.com"
)
),
tags(
(name = "todos", description = "ToDo API Endpoints")
),
paths(
crate::handlers::todo::get_by_id,
)
)]
struct OpenApiDocs {}
API Request and Response Model Documentation
The ToDo-API provides dedicated request and response models to encapsulate the API from the underlying data structures used for persistence. By using dedicated API models, you can design clean and efficient API contracts and provide fine-grained validation logic.
Documenting your API models helps humans and computers to understand their contextual meaning. With utoipa
, documenting these models is done by using the ToSchema
derive macro. The ToSchema
derive macro integrates seamlessly with Rust doc comments for specifying the title of a particular model and its fields. Also, rename instructions from serde
are taken into context as well.
You can use the #[schema]
macro to provide samples and perform further customizations:
use serde_json::json;
use utoipa::ToSchema;
/// ToDo-item
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
#[schema(example = json!({ "id": "059c7906-ce72-4433-94df-441beb14d96a", "contents": "Buy Milk", "isCompleted": false}))]
pub struct ToDoModel {
/// Unique identifier
id: Uuid,
/// ToDo contents
contents: String,
/// Is Completed?
is_completed: bool,
}
API Endpoint Documentation
With the “General API documentation” and a response model documentation in place, we can move on and take a look at an endpoint documentation. For illustration purposes, we’ll document the GET /api/todos/:id
endpoint. To do so, we must decorate the corresponding handler get_by_id
(located in src/handlers/todo.rs
) with the #[utoipa::path]
attribute macro.
The #[utoipa::path]
macro has an extensive API, allowing you to tailor all aspects of the endpoint-specific documentation. Take a minute to familiarize yourself with the macro and its capabilities :
#[utoipa::path(
get,
path = "/api/todos/{id}",
tags = ["todos"],
description = "Retrieve a ToDo-item using its identifier",
params(
("id" = Uuid, Path, description = "ToDo identifier")
),
responses(
(status = 200, description = "Desired ToDo-item", body = ToDoModel),
(status = 400, description = "Bad Request"),
(status = 404, description = "Desired ToDo-item was not found"),
(status = 500, description = "Internal Server Error")
)
)]
pub(crate) fn get_by_id(_req: Request, p: Params) -> Result<impl IntoResponse> {
// ...
}
Note that in contrast to the Spin HTTP router,
utoipa
expects route parameters to be specified using curly braces.
Exposing The OpenAPI Description
To expose the OpenAPI description, we first have to add another GET
endpoint to the Spin application. To do so, use router.get
in the entry point function (located in src/lib.rs
):
#[http_component]
fn handle_spin_todo_api(req: Request) -> anyhow::Result<impl IntoResponse> {
let mut router = Router::default();
//...
router.get("/docs/openapi-description.json", handlers::docs::get_openapi_description);
//...
Ok(router.handle(req))
}
Next, we have to add the actual handler in src/handlers/docs.rs
and use our custom OpenApi
struct:
use spin_sdk::http::{IntoResponse, Params, Request, ResponseBuilder};
pub fn get_openapi_description(_req: Request, _: Params) -> anyhow::Result<impl IntoResponse> {
let openapi_description = OpenApiDocs::openapi();
Ok(ResponseBuilder::new(200)
.header("content-type", "application/json")
.body(openapi_description.to_pretty_json()?)
.build())
}
With the handler in place, let’s compile Spin application and test the new endpoint on our local machine:
# Compile the Spin application
spin build
# Run the Spin application
spin up
From within a new terminal instance, use a tool like curl
and send a GET
request to the new endpoint at http://localhost:3000/docs/openapi-description.json
. You should see the Spin application responding with the prettified OpenAPI Description as JSON.
The OpenAPI Description could also be exposed as YAML file. To do so, use the optional
yaml
feature from theutoipa
crate.
Adding OpenAPI Documentation UI
With the machine-readable OpenAPI Description in place, we want to go one step further and make our Spin application serve an OpenAPI Documentation UI. With utoipa
, you can choose from different OpenAPI Documentation UIs, including:
- Swagger UI using the
utoipa-swagger-ui
crate - Redoc using the
utoipa-redoc
crate - RapiDoc using the
utoipa-rapidoc
crate
Follow these steps to add good old Swagger UI to the Spin application:
-
Add
utoipa-swagger-ui
as a dependency (cargo add utoipa-swagger-ui
) -
Register a new route after the OpenAPI Description route:
#[http_component] fn handle_spin_todo_api(req: Request) -> anyhow::Result<impl IntoResponse> { let mut router = Router::default(); // ... router.get("/docs/openapi-description.json", handlers::docs::get_openapi_description); router.get("/docs/*", handlers::docs::render_openapi_docs_ui); Ok(router.handle(req)) }
-
Add a new handler to
src/handlers/docs.rs
which will examine the request path and render the Swagger UI for all requests not targeting the OpenAPI Description:use std::sync::Arc; pub fn render_openapi_docs_ui(req: Request, _p: Params) -> anyhow::Result<impl IntoResponse> { let mut path = req .header("spin-path-info") .expect("spin-path-info is not present") .as_str() .unwrap_or("/") .to_string(); path = path.replace("/docs/", ""); let config = Arc::new(utoipa_swagger_ui::Config::from("openapi-description.json")); Ok(match utoipa_swagger_ui::serve(path.as_ref(), config) { Ok(swagger_file) => swagger_file .map(|file| { ResponseBuilder::new(200) .header("content-type", file.content_type) .body(file.bytes.to_vec()) .build() }) .unwrap_or_else(|| Response::new(404, "Not Found")), Err(_) => Response::new(500, "Internal Server Error"), }) }
Compile the Spin application and run it again on your development machine(spin build && spin up
). Once compilation has finished and Spin CLI has spawned the listener on port 3000
, browse http://localhost:3000/docs/
which should render Swagger UI and have the OpenAPI Description already loaded for you (as shown in the following figure):
Customizing OpenAPI Description
Although we provided plenty of information about the ToDo-API, we now want to go one step further and customize the OpenAPI Description based on incoming request circumstances.
We are going to extend the OpenAPI Description by adding a different Server depending on the host that is serving the documentation. This allows us to have a Development Server specified in the OpenAPI Description if we run the application on our local machine using spin up
.
However, if we deploy the application to a remote runtime - such as Fermyon Wasm Functions - we want the Production Server to be specified in the OpenAPI Description document.
This simple example illustrates how you could customize the OpenAPI Description document before serving it over HTTP.
Customizations in utoipa
are either achieved by adding custom Modifiers (custom structs that implement the Modify
trait) or by mutating our instance of OpenApiDocs
before sending it as an HTTP response body.
We’ll use the latter approach for adding a different server based on incoming request headers. To achieve this, update the get_openapi_description
handler (in src/handlers/docs.rs
) as shown here:
use utoipa::ServerBuilder;
use url::Url;
pub fn get_openapi_description(req: Request, _: Params) -> anyhow::Result<impl IntoResponse> {
let mut openapi_description = OpenApiDocs::openapi();
let (url, description) = get_server_info(&req);
openapi_description.servers = Some(vec![
ServerBuilder::new()
.url(url)
.description(Some(description))
.build(),
]);
Ok(ResponseBuilder::new(200)
.header("content-type", "application/json")
.body(openapi_description.to_pretty_json()?)
.build())
}
fn get_server_info(req: &Request) -> (String, String) {
match is_local_spin_runtime(&req) {
true => {
let full_url = req
.header("spin-full-url")
.expect("spin-full-url should be set when running api with spin CLI")
.as_str()
.expect("spin-full-url shall not be empty when running api with spin");
let u = Url::parse(full_url).expect("spin-full-url should be a valid url");
(
format!(
"{}://{}/",
u.scheme(),
format!(
"{}:{}",
u.host_str().expect("spin-full-url should have host"),
u.port().expect("spin-full-url should have port")
)
),
String::from("Local Development Server"),
)
}
false => {
let host = req
.header("x-forwarded-host")
.expect("x-forwarded-host should be set via FWF")
.as_str()
.expect("x-forwarded-host shall not be empty on FWF")
.to_string();
(
format!("https://{host}/"),
String::from("Production Server"),
)
}
}
}
fn is_local_spin_runtime(req: &Request) -> bool {
req.header("spin-client-addr").is_some()
}
With the customization in place, you can build and run the Spin application again. When browsing the Swagger UI again, you’ll recognize the Development Server being displayed:
Conclusion
By integrating the utoipa
crate into Spin applications, we can keep our API implementation and documentation in perfect sync while reducing manual overhead. What starts with a handful of macros quickly results in a fully navigable OpenAPI Description, complete with Swagger UI, and even adaptable to runtime conditions. Whether you’re building for local development or deploying at scale, this approach ensures your APIs remain transparent, discoverable, and ready for both humans and machines to consume. Instead of treating documentation as an afterthought, you embed it directly into the lifecycle of your service. In other words, your documentation becomes as alive as your code—always current, always reliable, and always serving as the single source of truth for anyone who needs to understand or integrate with your API.