Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

API reference

Publish Rails API documentation from rswag request specs

Describe your Rails API in rswag request specs that test every documented response, write OpenAPI with real recorded examples, and rebuild the docs only when the specs pass.

By 22 min read

To publish Rails API documentation with rswag, describe each endpoint in an rswag request spec, run the specs to write an OpenAPI file, and build a Blume site from that file. The specs send real requests to your controllers and check every response against the schema you documented, so the reference describes what the API actually does, and its examples are responses the API returned during the test run.

By the end of this guide, you have a Rails 8.1 API with bearer-token auth, request specs that test every documented response, an openapi.yaml with recorded request and response examples, and a docs site with a page per operation and a quickstart beside them. A GitHub Actions workflow runs the specs, checks the committed spec, then builds and deploys the docs, in that order, so a failing spec never reaches the published docs.

rswag can also serve Swagger UI from your Rails app at /api-docs. If an explorer for your own team is all you need, that's less to run. A separate docs site fits when the docs are public, need guides beside the reference, or shouldn't be served by the API they describe. With Blume as the docs site, you need only one of rswag's gems.

How the pieces fit

The docs project lives inside the Rails repository, in its own folder:

acme-api/                       the Rails app
├── app/
├── spec/
│   ├── swagger_helper.rb       rswag's settings and the spec's header
│   └── requests/               one request spec file per resource
├── docs-site/
│   ├── openapi.yaml            written by the specs, read by Blume
│   ├── blume.config.ts
│   ├── docs/
│   └── package.json
└── .github/workflows/docs.yml

rswag's rswag:specs:swaggerize task, run with RSWAG_DRY_RUN=0, runs the request specs and writes docs-site/openapi.yaml. Blume reads that file at build time and renders a page for each operation under /api. Blume never reads your Ruby, so the request specs are where the docs get better, and building the docs needs Node.js but no Ruby.

Create the API

The example is a small API for projects and their tasks, with an API key per client. Rails 8.1 needs Ruby 3.2 or later; this guide ran on Ruby 4.0. Install Rails at a fixed version and create an API-only app on SQLite:

gem install rails -v 8.1.4
rails _8.1.4_ new acme-api --api --database=sqlite3 --skip-test
cd acme-api

--api leaves out views and the middleware a browser app needs. --skip-test leaves out Minitest, because RSpec runs the tests here. Generate the models and migrate:

bin/rails generate model ApiKey name:string token:token --no-test-framework
bin/rails generate model Project name:string description:text --no-test-framework
bin/rails generate model Task project:references title:string due_on:date --no-test-framework
bin/rails db:migrate

The token type gives ApiKey has_secure_token and a unique index, so every key gets a random token when it's created. Add the associations and validations to the other two models:

class Project < ApplicationRecord
  has_many :tasks, dependent: :destroy

  validates :name, presence: true, length: { maximum: 100 }
end
class Task < ApplicationRecord
  belongs_to :project

  validates :title, presence: true, length: { maximum: 200 }
end

The application controller checks the API key on every request and turns a missing record into a JSON 404. authenticate_with_http_token reads the token from an Authorization header that starts with Bearer or Token:

class ApplicationController < ActionController::API
  include ActionController::HttpAuthentication::Token::ControllerMethods

  before_action :authenticate

  rescue_from ActiveRecord::RecordNotFound do
    render json: { error: "Not found" }, status: :not_found
  end

  private

  def authenticate
    authenticate_with_http_token { |token| ApiKey.find_by(token:) } ||
      render(json: { error: "Missing or invalid API key" }, status: :unauthorized)
  end
end
class ProjectsController < ApplicationController
  def index
    render json: Project.order(:id)
  end

  def show
    render json: Project.find(params[:id])
  end

  def create
    project = Project.new(params.expect(project: [ :name, :description ]))

    if project.save
      render json: project, status: :created
    else
      render json: { errors: project.errors }, status: :unprocessable_content
    end
  end
end
class TasksController < ApplicationController
  before_action :set_project

  def index
    render json: @project.tasks.order(:id)
  end

  def create
    task = @project.tasks.new(params.expect(task: [ :title, :due_on ]))

    if task.save
      render json: task, status: :created
    else
      render json: { errors: task.errors }, status: :unprocessable_content
    end
  end

  private

  def set_project
    @project = Project.find(params[:project_id])
  end
end

Two Rails defaults matter here. render json: project serializes every column, so a migration that adds one changes the API; the specs catch that later in this guide. And Rails wraps the keys of a JSON body that match the model's attributes under the model's name, so a body of {"name": "Website relaunch"} arrives as params[:project][:name], which is what params.expect(project: [...]) reads. Serve both resources under /v1:

Rails.application.routes.draw do
  scope "v1" do
    resources :projects, only: [ :index, :show, :create ] do
      resources :tasks, only: [ :index, :create ]
    end
  end

  # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
  # Can be used by load balancers and uptime monitors to verify that the app is live.
  get "up" => "rails/health#show", as: :rails_health_check
end

Add rswag

rswag is three gems, and with a Blume docs site you need only one of them:

GemWhat it doesNeeded here
rswag-specsThe request spec DSL, and the rake task that writes the OpenAPI fileYes
rswag-apiServes the OpenAPI file from your Rails appNo: Blume reads the file from the repository
rswag-uiServes Swagger UI from your Rails appNo: Blume renders the reference

Add RSpec and rswag-specs to the group rails new already created, so the app doesn't load either in production:

group :development, :test do
  # ...the gems rails new added

  # Request specs that test the API and write its OpenAPI document
  gem "rspec-rails", "~> 8.0"
  gem "rswag-specs", "~> 2.17"
end
bundle install
bin/rails generate rspec:install
bin/rails generate rswag:specs:install

The first generator writes .rspec, spec/spec_helper.rb, and spec/rails_helper.rb; the second writes spec/swagger_helper.rb. rswag's README starts with rails g rswag:install, which comes from the rswag gem that bundles all three, and doesn't exist with rswag-specs alone. Read the README at the tag of the version you install: the default branch on GitHub documents rswag 3.0, which is only out as a prerelease, with an openapi_helper.rb and a different way to pass parameters.

Point rswag at the docs site

Replace the generated spec/swagger_helper.rb. It says where rswag writes the file, holds everything in the OpenAPI document that isn't an operation, and records examples from the test run:

# frozen_string_literal: true

require "rails_helper"

RSpec.configure do |config|
  # Write the OpenAPI document into the Blume project, where the docs build reads it.
  config.openapi_root = Rails.root.join("docs-site").to_s
  config.openapi_format = :yaml

  # Fail a spec when a response has a field its schema doesn't list.
  config.openapi_no_additional_properties = true

  config.openapi_specs = {
    "openapi.yaml" => {
      openapi: "3.0.3",
      info: {
        title: "Acme Projects API",
        version: "1.0.0",
        description: "Create projects and track the tasks in them with a JSON API. Every request needs an API key, sent as a bearer token."
      },
      servers: [ { url: "https://api.acme.example/v1" } ],
      security: [ { bearer_auth: [] } ],
      tags: [
        { name: "Projects", description: "Create projects and look them up." },
        { name: "Tasks", description: "Add tasks to a project and list them." }
      ],
      paths: {},
      components: {
        securitySchemes: {
          bearer_auth: {
            type: :http,
            scheme: :bearer,
            description: "An API key, sent as a bearer token."
          }
        },
        schemas: {
          Project: {
            type: :object,
            properties: {
              id: { type: :integer, description: "The project's ID." },
              name: { type: :string, description: "The project's name." },
              description: { type: :string, nullable: true, description: "What the project is for." },
              created_at: { type: :string, format: "date-time", description: "When the project was created." },
              updated_at: { type: :string, format: "date-time", description: "When the project last changed." }
            },
            required: %w[id name description created_at updated_at]
          },
          ProjectInput: {
            type: :object,
            properties: {
              name: { type: :string, maxLength: 100, description: "The project's name." },
              description: { type: :string, nullable: true, description: "What the project is for." }
            },
            required: %w[name]
          },
          Task: {
            type: :object,
            properties: {
              id: { type: :integer, description: "The task's ID." },
              project_id: { type: :integer, description: "The ID of the project the task belongs to." },
              title: { type: :string, description: "What needs doing." },
              due_on: { type: :string, format: :date, nullable: true, description: "The day the task is due." },
              created_at: { type: :string, format: "date-time", description: "When the task was created." },
              updated_at: { type: :string, format: "date-time", description: "When the task last changed." }
            },
            required: %w[id project_id title due_on created_at updated_at]
          },
          TaskInput: {
            type: :object,
            properties: {
              title: { type: :string, maxLength: 200, description: "What needs doing." },
              due_on: { type: :string, format: :date, nullable: true, description: "The day the task is due." }
            },
            required: %w[title]
          },
          Error: {
            type: :object,
            properties: {
              error: { type: :string, description: "What went wrong." }
            },
            required: %w[error]
          },
          ValidationErrors: {
            type: :object,
            properties: {
              errors: {
                type: :object,
                description: "The invalid fields, each with its problems.",
                additionalProperties: { type: :array, items: { type: :string } }
              }
            },
            required: %w[errors]
          }
        }
      }
    }
  }

  # Request specs run at a fixed time, so timestamps in the recorded examples
  # don't change from one run to the next.
  config.include ActiveSupport::Testing::TimeHelpers
  config.around(:each, :rswag) do |example|
    travel_to(Time.utc(2026, 1, 15, 9, 30)) { example.run }
  end

  # Record each passing request spec's response as the example for its
  # status code, and a successful request's body as the request example.
  config.after(:each, :rswag) do |example|
    next if example.exception

    if response.body.present?
      example.metadata[:response][:content] = {
        "application/json" => { example: JSON.parse(response.body) }
      }
    end

    if response.successful? && request.raw_post.present?
      example.metadata[:operation][:request_examples] = [
        { name: "example", value: JSON.parse(request.raw_post) }
      ]
    end
  end
end
  • Where the file goes. openapi_root and the key openapi.yaml add up to docs-site/openapi.yaml, beside the Blume config you'll add later. Without openapi_format, rswag writes JSON.
  • OpenAPI 3.0. rswag checks responses with JSON Schema draft 4 plus OpenAPI 3.0's nullable, so the schemas are 3.0 schemas. Blume upgrades a 3.0 document to 3.1 as it reads it, which turns nullable: true into a string-or-null type. Declare 3.1.0 instead and Blume takes the file as written and warns BLUME_OPENAPI_NULLABLE: 3.1 has no nullable, so other 3.1 tools read description as never null.
  • The /v1 prefix. The server URL ends in /v1 and the paths don't. When rswag sends a test request, it puts the path of the first server URL in front of the spec's path, so /projects is requested at /v1/projects, where Rails routes it, and the docs show /projects under the base URL.
  • Auth. The root security applies bearer_auth to every operation. rswag sends the Authorization header from a let(:Authorization) in each spec, and Blume shows an Authorization section, a bearer token field in Try it, and Authorization: Bearer YOUR_TOKEN in the code samples.
  • Tags. The tags list sets the order of the groups in the sidebar and on the overview page, where each description introduces its group.
  • Strict responses. openapi_no_additional_properties fails a spec when a response has a field its schema doesn't list, so a field a tested response returns can't go undocumented.

Recorded examples

The last two blocks are what make the examples real. rswag sends a request for every run_test!, and its README suggests an after block in each response to save that response as its example. The helper does it once, for every spec that run_test! creates, since rswag tags those with :rswag:

  • The response body becomes the example for that status code. Blume's Response panel shows a tab per status code, so a reader sees the 201 and the 422 the API really sent.
  • A successful request's body becomes the request example. rswag writes it under the operation's requestBody, and Blume uses it in the code samples and as Try it's starting values.
  • next if example.exception skips specs that failed. An after hook runs after a failure too, and without the guard a 201 spec that got the wrong body would document the wrong body.
  • travel_to pins every timestamp, and SQLite rolls back its ID counter with each spec's transaction, so IDs start at 1 in every spec. The same code then writes the same file, byte for byte, which the CI check later in this guide depends on.

Describe the endpoints in request specs

One spec file per resource:

require "swagger_helper"

RSpec.describe "Projects", type: :request do
  let(:api_key) { ApiKey.create!(name: "Docs") }
  let(:Authorization) { "Bearer #{api_key.token}" }

  path "/projects" do
    get "List projects" do
      tags "Projects"
      operationId "listProjects"
      description "Returns every project in your account, oldest first."
      produces "application/json"

      response "200", "The projects." do
        schema type: :array, items: { "$ref" => "#/components/schemas/Project" }

        before do
          Project.create!(name: "Website relaunch", description: "Move the marketing site to the new design.")
          Project.create!(name: "Mobile app", description: nil)
        end

        run_test! do |response|
          expect(JSON.parse(response.body).map { |project| project["name"] })
            .to eq([ "Website relaunch", "Mobile app" ])
        end
      end

      response "401", "The API key is missing or invalid." do
        schema "$ref" => "#/components/schemas/Error"

        let(:Authorization) { "Bearer not-a-real-key" }

        run_test!
      end
    end

    post "Create a project" do
      tags "Projects"
      operationId "createProject"
      description "Creates a project. Add tasks to it with [Create a task](/api/tasks/create-task)."
      consumes "application/json"
      produces "application/json"
      parameter name: :project, in: :body, required: true, schema: { "$ref" => "#/components/schemas/ProjectInput" }

      response "201", "The project was created." do
        schema "$ref" => "#/components/schemas/Project"

        let(:project) { { name: "Website relaunch", description: "Move the marketing site to the new design." } }

        run_test!
      end

      response "422", "The project is invalid. Each invalid field is listed with its problems." do
        schema "$ref" => "#/components/schemas/ValidationErrors"

        let(:project) { { name: "" } }

        run_test!
      end
    end
  end

  path "/projects/{id}" do
    parameter name: :id, in: :path, schema: { type: :integer }, example: 1, description: "The project's ID."

    get "Get a project" do
      tags "Projects"
      operationId "getProject"
      description "Returns one project by its ID, with its name, description, and timestamps."
      produces "application/json"

      response "200", "The project." do
        schema "$ref" => "#/components/schemas/Project"

        let(:id) { Project.create!(name: "Website relaunch", description: "Move the marketing site to the new design.").id }

        run_test!
      end

      response "404", "No project has that ID." do
        schema "$ref" => "#/components/schemas/Error"

        let(:id) { 999 }

        run_test!
      end
    end
  end
end
require "swagger_helper"

RSpec.describe "Tasks", type: :request do
  let(:api_key) { ApiKey.create!(name: "Docs") }
  let(:Authorization) { "Bearer #{api_key.token}" }
  let(:website) { Project.create!(name: "Website relaunch", description: "Move the marketing site to the new design.") }

  path "/projects/{project_id}/tasks" do
    parameter name: :project_id, in: :path, schema: { type: :integer }, example: 1, description: "The ID of the project."

    get "List tasks" do
      tags "Tasks"
      operationId "listTasks"
      description "Returns the tasks in a project, oldest first."
      produces "application/json"

      response "200", "The project's tasks." do
        schema type: :array, items: { "$ref" => "#/components/schemas/Task" }

        let(:project_id) { website.id }

        before do
          website.tasks.create!(title: "Write the launch post", due_on: "2026-02-02")
          website.tasks.create!(title: "Update the screenshots", due_on: nil)
        end

        run_test!
      end

      response "404", "No project has that ID." do
        schema "$ref" => "#/components/schemas/Error"

        let(:project_id) { 999 }

        run_test!
      end
    end

    post "Create a task" do
      tags "Tasks"
      operationId "createTask"
      description "Adds a task to a project."
      consumes "application/json"
      produces "application/json"
      parameter name: :task, in: :body, required: true, schema: { "$ref" => "#/components/schemas/TaskInput" }

      response "201", "The task was created." do
        schema "$ref" => "#/components/schemas/Task"

        let(:project_id) { website.id }
        let(:task) { { title: "Write the launch post", due_on: "2026-02-02" } }

        run_test!
      end

      response "422", "The task is invalid. Each invalid field is listed with its problems." do
        schema "$ref" => "#/components/schemas/ValidationErrors"

        let(:project_id) { website.id }
        let(:task) { { title: "" } }

        run_test!
      end

      response "404", "No project has that ID." do
        schema "$ref" => "#/components/schemas/Error"

        let(:project_id) { 999 }
        let(:task) { { title: "Write the launch post" } }

        run_test!
      end
    end
  end
end

Each response block is a test. rswag builds the request from the parameters and the lets, sends it, then checks the status code and the body against schema. The let names match the parameter names: let(:project) is the request body, let(:id) fills in {id}, and let(:Authorization) is the header.

In the spec fileIn openapi.yamlOn the docs site
post "Create a project"The operation and its summaryThe page title and sidebar label
tags "Projects"The operation's tagsIts sidebar group, and the URL segment /api/projects/
operationId "createProject"operationIdThe page URL, /api/projects/create-project
descriptiondescription, as MarkdownThe paragraph under the title, and the start of the page's meta description
parameter ... in: :body and consumesrequestBodyThe Request body table, and Try it's body fields
parameter ... in: :path, example: 1A path parameter with an exampleThe Path parameters table, and /projects/1 in the samples
response "422", "The project is invalid..."A response and its descriptionA status code under Responses, with the text beside it
schemaThe response's schemaThe response's schema table; the spec also checks the body against it
The helper's after hookexample and the request's examplesThe Response panel, the code samples, and Try it's starting values

A few details matter for the docs:

  • rswag doesn't write an operationId of its own. Without one, Blume builds the URL from the method and path, like /api/projects/post-projects.
  • example: 1 on the path parameters is written by hand. The hook records bodies, not the values in the URL, and without an example the samples call /projects/0.
  • Descriptions are Markdown. The link to Create a task renders on the page, and blume validate checks it like a link in any page you write, so it fails if that operation's URL changes.
  • type: :request is required. rspec-rails' rails_helper.rb doesn't infer a spec's type from its folder unless you turn that on, and rswag's DSL only exists in request specs.
  • A response written as response "401", "...", document: false still runs as a test but stays out of the file.

Write the spec from the tests

Run the request specs and write the file:

RSWAG_DRY_RUN=0 bin/rails rswag:specs:swaggerize
Generating Swagger docs ...
Swagger doc generated at /path/to/acme-api/docs-site/openapi.yaml

11 examples, 0 failures

RSWAG_DRY_RUN=0 is what makes the task run the tests. By default it passes RSpec's --dry-run: no request is sent and no hook runs, so it writes a file with schemas and no examples, whether the specs would pass or not. rswag's README also mentions a rswag_dry_run setting for config/environments/test.rb, but the rake task decides before that file loads, and setting it there had no effect. Use the environment variable.

The task runs the specs under spec/requests, spec/api, and spec/integration, in the order they're defined. Here's what it wrote for Create a project:

    post:
      summary: Create a project
      tags:
      - Projects
      operationId: createProject
      description: Creates a project. Add tasks to it with [Create a task](/api/tasks/create-task).
      parameters: []
      responses:
        '201':
          description: The project was created.
          content:
            application/json:
              example:
                id: 1
                name: Website relaunch
                description: Move the marketing site to the new design.
                created_at: '2026-01-15T09:30:00.000Z'
                updated_at: '2026-01-15T09:30:00.000Z'
              schema:
                "$ref": "#/components/schemas/Project"
        '422':
          description: The project is invalid. Each invalid field is listed with its
            problems.
          content:
            application/json:
              example:
                errors:
                  name:
                  - can't be blank
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
      requestBody:
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/ProjectInput"
            examples:
              example:
                summary: Create a project
                value:
                  name: Website relaunch
                  description: Move the marketing site to the new design.
        required: true

When a spec fails, the task exits with an error but writes the file anyway, without the examples of the specs that failed. A run without RSWAG_DRY_RUN=0 writes it with no examples at all. Don't commit the file from either: fix what failed and run the task again.

Build the docs site

From the Rails root, scaffold Blume into the folder that holds the spec. Blume needs Node.js 22.19 or later:

npx blume init docs-site --template docs --yes

It writes blume.config.ts, docs/index.mdx, a package.json with dev and build scripts, and a .gitignore into docs-site/, installs Blume, and leaves openapi.yaml alone. Replace the config:

import { defineConfig } from "blume";
import { openapi } from "blume/reference";

export default defineConfig({
  title: "Acme Docs",
  description: "Guides and API reference for the Acme Projects API.",
  deployment: { site: "https://docs.acme.example" },
  navigation: {
    tabs: [
      { label: "Guides", path: "/" },
      { label: "API reference", path: "/api" },
    ],
  },
  reference: [
    openapi({
      route: "/api",
      spec: "./openapi.yaml",
      codeSamples: ["curl", "ruby", "js", "python"],
    }),
  ],
});

The reference doesn't add a header tab on its own, so the tab at /api makes it reachable and scopes its sidebar. codeSamples adds a Ruby tab, using net/http, next to cURL, JavaScript, and Python. deployment.site gives the sitemap and canonical links their origin, since GitHub Pages doesn't tell the build its URL.

The reference says what each operation takes and returns. Readers also need to know how to authenticate and which call comes first, so replace the home page and add a quickstart:

---
title: Introduction
description: "Create projects and track their tasks with the Acme Projects API: authenticate with an API key and call it from any HTTP client."
---

The Acme Projects API stores your projects and the tasks in them.

- [Quickstart](/quickstart) takes you from an API key to your first task.
- The [API reference](/api) lists every operation. Its examples are real
  responses, recorded while the API's test suite ran.
---
title: Quickstart
description: Authenticate with an API key, create a project with the Acme Projects API, and add your first task to it from the command line.
---

Every request needs an API key, sent in the `Authorization` header as a bearer
token. Set it in your shell first:

```bash
export ACME_API_KEY="your-api-key"
```

## Create a project

Send a name to [Create a project](/api/projects/create-project):

```bash
curl https://api.acme.example/v1/projects \
  -H "Authorization: Bearer $ACME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Website relaunch"}'
```

The response is the project, with the ID the API assigned:

```json
{
  "id": 1,
  "name": "Website relaunch",
  "description": null,
  "created_at": "2026-01-15T09:30:00.000Z",
  "updated_at": "2026-01-15T09:30:00.000Z"
}
```

## Add a task

Pass the project's ID to [Create a task](/api/tasks/create-task):

```bash
curl https://api.acme.example/v1/projects/1/tasks \
  -H "Authorization: Bearer $ACME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"Write the launch post","due_on":"2026-02-02"}'
```

## Handle errors

A request with a missing or unknown key gets a `401` with an `error` message.
An invalid body gets a `422`, with each invalid field listed under `errors`:

```json
{ "errors": { "name": ["can't be blank"] } }
```

Link to operation pages by their URL, which comes from the tag and the operation ID. Build the site, check every link, and preview it:

cd docs-site
npm run build
npx blume validate --strict
npx blume preview

--strict fails on warnings as well as broken links. Open http://localhost:4321/api. The overview lists both tags with their descriptions, and each operation has its own page:

OperationPage
GET /projects/api/projects/list-projects
POST /projects/api/projects/create-project
GET /projects/{id}/api/projects/get-project
GET /projects/{project_id}/tasks/api/tasks/list-tasks
POST /projects/{project_id}/tasks/api/tasks/create-task

Each page has the summary as its title, the description under it, an Authorization section, and schema tables with the descriptions from swagger_helper.rb. The code samples and the Response panel come from the recorded examples: Create a project's cURL sample sends the body the 201 spec sent, and its Response panel has a 201 tab with the created project and a 422 tab with {"errors": {"name": ["can't be blank"]}}.

While you write specs, run npm run dev in docs-site/ and the swaggerize task in another terminal. blume dev watches openapi.yaml, so the open page reloads each time the task rewrites it.

One more file outside docs-site/: the Dockerfile from rails new copies the whole app into the image, docs site included. Add a line to .dockerignore:

/docs-site

Let Try it call the API

The Try it panel sends requests from the reader's browser to the server in the spec, so the API has to allow the docs site's origin with CORS. The Gemfile from rails new --api already lists rack-cors, commented out. Uncomment it, run bundle install, and replace the commented-out initializer:

# Let the docs site's Try it panel call the API from the browser.
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "https://docs.acme.example"

    resource "/v1/*",
      headers: %w[Authorization Content-Type],
      methods: %i[get post options]
  end
end

headers lists the two Try it sends: the key and the body's type. A preflight from https://docs.acme.example then gets the origin back in Access-Control-Allow-Origin, and one from any other origin doesn't. To try it against bin/rails server while you work, add your local docs origin, like http://localhost:4321, to origins, and type http://localhost:3000/v1 in Try it's Custom base URL field. On Create a project, Send without a key returns a 401. Create a key with bin/rails runner 'puts ApiKey.create!(name: "Local").token', paste it into the bearer token field, and Send returns the 201. Fix CORS errors in an API documentation playground covers reading a blocked preflight, and Blume's proxy for an API you can't change.

Rebuild the docs only when the specs pass

Commit openapi.yaml even though it's generated. Its diff in a pull request shows the API change the way clients will see it, a host can build the docs from it without Ruby, and tools like a breaking-change check can read the base branch's copy. Then add a workflow that runs the specs, checks the committed file, and only then builds and deploys:

name: API docs

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  docs:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
      - name: Run the request specs and write the spec
        run: bin/rails db:test:prepare rswag:specs:swaggerize
        env:
          RSWAG_DRY_RUN: "0"
      - name: Fail if the committed spec is out of date
        run: git diff --exit-code -- docs-site/openapi.yaml
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
          cache-dependency-path: docs-site/package-lock.json
      - run: npm ci
        working-directory: docs-site
      - run: npx blume validate --strict
        working-directory: docs-site
      - run: npm run build
        working-directory: docs-site
      - if: github.event_name == 'push'
        uses: actions/upload-pages-artifact@v5
        with:
          path: docs-site/dist
          include-hidden-files: true

  deploy:
    if: github.event_name == 'push'
    needs: docs
    runs-on: ubuntu-latest
    permissions:
      pages: write
      id-token: write
    concurrency:
      group: pages
      cancel-in-progress: false
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@v5
  • The specs come first. db:test:prepare creates the test database from db/schema.rb, and the swaggerize task runs the request specs and writes the file. A failing spec fails the step, and nothing after it runs: no build, no deploy. ruby/setup-ruby reads .ruby-version, which rails new wrote.
  • The freshness check compares the file the specs just wrote with the committed one. It fails when someone changed the API or the specs without running the task, or committed the file from a red or dry run. git diff only compares files Git tracks, so commit openapi.yaml before you add the check.
  • The docs build checks every link, including the ones to operation pages, then builds the site from a spec the specs just proved.
  • The deploy runs only for pushes to main, and only once the docs job has passed.

The swaggerize task runs only request specs. If your other specs should gate the docs too, run bundle exec rspec in a job that docs lists under needs.

When a pull request changes the API

Say a pull request lets projects be archived, with a new column:

bin/rails generate migration AddArchivedAtToProjects archived_at:datetime
bin/rails db:migrate

render json: project now returns archived_at, and nothing documents it. Run the task, and the strict schemas fail the three specs that return a project:

Failures:

  1) Projects /projects get The projects. returns a 200 response
     Failure/Error: travel_to(Time.utc(2026, 1, 15, 9, 30)) { example.run }

     Rswag::Specs::UnexpectedResponse:
       Expected response body to match schema: The property '#/0' contained undefined properties: 'archived_at' in schema 05d38a59-14ed-50c4-96c2-ab9573a4c553#
...
11 examples, 3 failures

Failed examples:

rspec ./spec/requests/projects_spec.rb:14 # Projects /projects get The projects. returns a 200 response
rspec ./spec/requests/projects_spec.rb:45 # Projects /projects post The project was created. returns a 201 response
rspec ./spec/requests/projects_spec.rb:72 # Projects /projects/{id} get The project. returns a 200 response

Document the field in the Project schema:

               created_at: { type: :string, format: "date-time", description: "When the project was created." },
-              updated_at: { type: :string, format: "date-time", description: "When the project last changed." }
+              updated_at: { type: :string, format: "date-time", description: "When the project last changed." },
+              archived_at: { type: :string, format: "date-time", nullable: true, description: "When the project was archived, or null." }
             },
-            required: %w[id name description created_at updated_at]
+            required: %w[id name description created_at updated_at archived_at]

The specs pass again. If the pull request stops there, with the migration and the schema but not the file the task wrote, the freshness check fails and prints what the commit is missing, starting with:

@@ -33,11 +33,13 @@ paths:
                 description: Move the marketing site to the new design.
                 created_at: '2026-01-15T09:30:00.000Z'
                 updated_at: '2026-01-15T09:30:00.000Z'
+                archived_at:
               - id: 2
                 name: Mobile app
                 description:
                 created_at: '2026-01-15T09:30:00.000Z'
                 updated_at: '2026-01-15T09:30:00.000Z'
+                archived_at:
               schema:
                 type: array
                 items:

The other hunks add archived_at to the remaining project examples and to the schema. Run the task and commit openapi.yaml with the change. To also fail pull requests that break existing clients, like a removed field or a new required one, add oasdiff as shown in Keep API documentation in sync with backend changes in CI.

Deploy

The workflow deploys to GitHub Pages. Before the first push to main, set Source to GitHub Actions in the repository's Pages settings, and add docs.acme.example under Custom domain; Deploy Markdown docs to GitHub Pages covers both and the DNS record. include-hidden-files keeps the .well-known/ folder Blume writes for agent discovery.

To deploy somewhere else, replace the deploy job with your host's deploy step for docs-site/dist. If your host builds from Git by itself, like Vercel or Netlify, set its root directory to docs-site and its build command to npm run build. It builds from the committed spec without Ruby, and it doesn't wait for this workflow, so make the docs check required on pull requests. The deployment docs cover each host.

Limitations

  • One example per response. Each status code of each operation gets the body of the one spec that tested it, and each request body gets the body a successful spec sent. The helper's hook replaces any examples you add with rswag's request_body_example.
  • URL values aren't recorded. Path and query parameter examples are the ones you write on the parameter.
  • Stable output depends on the database. SQLite rolls back IDs with each spec's transaction. PostgreSQL never hands a sequence value back, so recorded IDs there depend on what earlier specs created, and the file changes when you add a spec. This guide wasn't run on PostgreSQL.
  • Written for OpenAPI 3.0. rswag checks every response with JSON Schema draft 4 plus 3.0's nullable, whatever version the document declares, so the schemas stay 3.0 schemas. Blume upgrades the file to 3.1 as it reads it.
  • Rails below 8.2. rswag-specs 2.17 requires railties and activesupport below 8.2, so check its gemspec before you upgrade Rails.
  • Agents get the examples, not every table. The Markdown copy of each operation page, which llms-full.txt and the .md URLs serve, has the summary, description, and endpoint, the request body's top-level fields, and the recorded examples, but not the parameters or nested schemas. Write the descriptions so they stand on their own.

Troubleshooting

The examples disappeared from openapi.yaml

The task ran as a dry run, without RSWAG_DRY_RUN=0, which writes no examples. Run it again with the variable, and check the workflow step sets it too.

Specs fail with "contained undefined properties"

The response has a field its schema doesn't list, usually a new column that render json: picked up. Add it to the schema in swagger_helper.rb, or leave it out of the JSON on purpose.

NoMethodError: undefined method 'Authorization'

The operation requires the bearer scheme, so rswag looks for a let(:Authorization) to send. Define one at the top of the spec file, as both files here do.

undefined method 'path' for class RSpec::ExampleGroups

The describe block is missing type: :request, so rswag's DSL isn't loaded for it.

Page URLs read post-projects instead of create-project

The operation has no operationId, so Blume used the method and path. Add one, and if the old URLs are already public, add a redirect for each in blume.config.ts.

The code samples call /projects/0

The path parameter has no example. Add one to its parameter line.

openapi.yaml lost a whole resource

The task ran with PATTERN set to one spec file, and rswag writes only the paths of the specs it ran. Run it without PATTERN before you commit.

The build fails with BLUME_OPENAPI_UNAVAILABLE

Blume couldn't read openapi.yaml. spec resolves from docs-site/, so check that the task wrote the file there and that it's committed. In blume dev this is only a warning and the reference is skipped, so a working dev server can hide it.

Next step

Add docs to your Rails API

Run it at the root of your Rails app once rswag writes docs-site/openapi.yaml, then mount the file with openapi().

npx blume init docs-site --template docs --yes
Add breaking-change checks in CI

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init