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 Hayden Bleasel22 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.ymlrswag'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:migrateThe 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 }
endclass Task < ApplicationRecord
belongs_to :project
validates :title, presence: true, length: { maximum: 200 }
endThe 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
endclass 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
endclass 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
endTwo 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
endAdd rswag
rswag is three gems, and with a Blume docs site you need only one of them:
| Gem | What it does | Needed here |
|---|---|---|
rswag-specs | The request spec DSL, and the rake task that writes the OpenAPI file | Yes |
rswag-api | Serves the OpenAPI file from your Rails app | No: Blume reads the file from the repository |
rswag-ui | Serves Swagger UI from your Rails app | No: 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"
endbundle install
bin/rails generate rspec:install
bin/rails generate rswag:specs:installThe 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_rootand the keyopenapi.yamladd up todocs-site/openapi.yaml, beside the Blume config you'll add later. Withoutopenapi_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 turnsnullable: trueinto a string-or-null type. Declare3.1.0instead and Blume takes the file as written and warnsBLUME_OPENAPI_NULLABLE: 3.1 has nonullable, so other 3.1 tools readdescriptionas never null. - The
/v1prefix. The server URL ends in/v1and 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/projectsis requested at/v1/projects, where Rails routes it, and the docs show/projectsunder the base URL. - Auth. The root
securityappliesbearer_authto every operation. rswag sends theAuthorizationheader from alet(:Authorization)in each spec, and Blume shows an Authorization section, a bearer token field in Try it, andAuthorization: Bearer YOUR_TOKENin the code samples. - Tags. The
tagslist 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_propertiesfails 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.exceptionskips specs that failed. Anafterhook runs after a failure too, and without the guard a 201 spec that got the wrong body would document the wrong body.travel_topins 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
endrequire "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
endEach 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 file | In openapi.yaml | On the docs site |
|---|---|---|
post "Create a project" | The operation and its summary | The page title and sidebar label |
tags "Projects" | The operation's tags | Its sidebar group, and the URL segment /api/projects/ |
operationId "createProject" | operationId | The page URL, /api/projects/create-project |
description | description, as Markdown | The paragraph under the title, and the start of the page's meta description |
parameter ... in: :body and consumes | requestBody | The Request body table, and Try it's body fields |
parameter ... in: :path, example: 1 | A path parameter with an example | The Path parameters table, and /projects/1 in the samples |
response "422", "The project is invalid..." | A response and its description | A status code under Responses, with the text beside it |
schema | The response's schema | The response's schema table; the spec also checks the body against it |
The helper's after hook | example and the request's examples | The Response panel, the code samples, and Try it's starting values |
A few details matter for the docs:
- rswag doesn't write an
operationIdof its own. Without one, Blume builds the URL from the method and path, like/api/projects/post-projects. example: 1on 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 validatechecks it like a link in any page you write, so it fails if that operation's URL changes. type: :requestis required. rspec-rails'rails_helper.rbdoesn'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: falsestill 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:swaggerizeGenerating Swagger docs ...
Swagger doc generated at /path/to/acme-api/docs-site/openapi.yaml
11 examples, 0 failuresRSWAG_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: trueWhen 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 --yesIt 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:
| Operation | Page |
|---|---|
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-siteLet 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
endheaders 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:preparecreates the test database fromdb/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-rubyreads.ruby-version, whichrails newwrote. - 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 diffonly compares files Git tracks, so commitopenapi.yamlbefore 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 thedocsjob 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:migraterender 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 responseDocument 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
railtiesandactivesupportbelow 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.txtand the.mdURLs 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 --yesA step here not working for you? Report a broken step.