August 19, 2025

OpenAPI Documentation for Spin Apps with Rust

Thorsten Hans Thorsten Hans

Spin Rust

OpenAPI Documentation for Spin Apps with Rust

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:

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-items
  • GET /api/todos/:id - Retrieve a particular ToDo-item using its identifier
  • POST /api/todos - Create a new ToDo-item
  • POST /api/todos/:id/toggle - Toggle a ToDo-item using its identifier
  • DELETE /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:

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 the utoipa 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:

Follow these steps to add good old Swagger UI to the Spin application:

  1. Add utoipa-swagger-ui as a dependency (cargo add utoipa-swagger-ui)

  2. 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))
    }
    
  3. 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):

Swagger UI generated by utoipa

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:

Customized Swagger UI generated by utoipa

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.

 


🔥 Recommended Posts


Quickstart Your Serverless Apps with Spin

Get Started