openapi_ruby
A unified OpenAPI toolkit for Rails, Hanami, and Rack that combines test-driven spec generation, reusable schema components as Ruby classes, and runtime request/response validation middleware. Supports OpenAPI 3.0 and 3.1. Works with both RSpec and Minitest.
Replaces rswag, rswag-schema-components, and committee with a single gem.
Key Features
- OpenAPI 3.0 & 3.1 with JSON Schema 2020-12 (via json_schemer)
- Test-framework agnostic — works with RSpec and Minitest
- Host-framework agnostic — works on Rails, Hanami, Sinatra, and bare Rack
- Schema components as Ruby classes with inheritance
- Runtime middleware for request/response validation with deep type checking
- Strong params derived from schema components
- Spec generation from test definitions
- Optional Swagger UI via CDN
Requirements
- Ruby >= 3.2
- Rails >= 7.0, Hanami >= 2.3, or any Rack app (see Host Frameworks)
Installation
Add to your Gemfile:
gem "openapi-ruby"
Run the install generator:
rails generate openapi_ruby:install
This creates:
config/initializers/openapi_ruby.rb— configurationspec/openapi_helper.rbortest/openapi_helper.rb— test helperapp/api_components/— directory for schema componentsopenapi/— output directory for generated specs- Engine mount in
config/routes.rb
The install generator is Rails-only — see Host Frameworks for the equivalent setup elsewhere.
Configuration
# config/initializers/openapi_ruby.rb
OpenapiRuby.configure do |config|
config.schemas = {
public_api: {
info: { title: "My API", version: "v1" },
servers: [{ url: "/" }]
}
}
config.component_paths = ["app/api_components"]
config.camelize_keys = true
config.schema_output_dir = "openapi"
config.schema_output_format = :yaml
# Runtime middleware (disabled by default)
config.request_validation = :disabled # :enabled, :disabled, :warn_only
config.response_validation = :disabled
# Test DSL: validate requests against declared operations before sending.
# Enabled by default; set to false to disable.
config.test_request_validation = true
end
OpenAPI Version
The default OpenAPI version is 3.1.0. To generate 3.0.x schemas (e.g., when using nullable: true):
config.schemas = {
public_api: {
openapi_version: "3.0.3",
info: { title: "My API", version: "v1" },
servers: [{ url: "/" }]
}
}
Multiple Schemas with Scopes
For projects with multiple APIs, use component_scope to partition components:
config.schemas = {
"internal/v1/schema": {
info: { title: "Internal API", version: "v1" },
component_scope: :internal_v1
},
"public/v2/schema": {
info: { title: "Public API", version: "v2" },
component_scope: :public_v2
}
}
# Infer scopes from directory structure (e.g., internal/v1/schemas/user.rb → :internal_v1)
config.component_scope_paths = {
"internal/v1" => :internal_v1,
"public/v2" => :public_v2
}
Components are automatically scoped based on their file path. Use shared_component to include a component in all schemas, or component_scopes :scope1, :scope2 to assign explicitly.
Schema Components
Define your API schemas as Ruby classes:
# app/api_components/schemas/user.rb
class Schemas::User
include OpenapiRuby::Components::Base
schema(
type: :object,
required: %w[id name email],
properties: {
id: { type: :integer, readOnly: true },
name: { type: :string },
email: { type: :string },
created_at: { type: [:string, :null], format: "date-time" }
}
)
end
Inheritance
class Schemas::AdminUser < Schemas::User
schema(
properties: {
role: { type: :string, enum: %w[admin superadmin] }
}
)
end
Child schemas deep-merge with their parent — AdminUser has all of User's properties plus role.
Component Types
class SecuritySchemes::BearerAuth
include OpenapiRuby::Components::Base
component_type :securitySchemes
schema(
type: :http,
scheme: :bearer,
bearerFormat: "JWT"
)
end
Supported types: schemas, parameters, securitySchemes, requestBodies, responses, headers, examples, links, callbacks.
Key Transformation
By default, snake_case keys are converted to camelCase in the output. Disable globally with config.camelize_keys = false or per-component:
class Schemas::User
include OpenapiRuby::Components::Base
skip_key_transformation true
# ...
end
Scopes
Assign components to scopes for multiple API specs:
class Schemas::AdminUser
include OpenapiRuby::Components::Base
component_scopes :admin
# ...
end
Class References
Instead of writing $ref strings manually, you can pass component classes directly anywhere a $ref is expected. This gives you typo protection (via NameError), IDE navigation, and less boilerplate:
# Instead of:
schema "$ref" => "#/components/schemas/User"
schema type: :array, items: { "$ref" => "#/components/schemas/User" }
# You can write:
schema Schemas::User
schema type: :array, items: Schemas::User
This works in schema, request_body, and anywhere nested inside hash/array definitions. Non-component classes raise ArgumentError.
You can also use the explicit .to_ref method:
Schemas::User.to_ref
# => { "$ref" => "#/components/schemas/User" }
Both class refs and string $ref hashes are fully supported — use whichever you prefer.
Strong Params
Schema components can derive Rails strong params permit lists:
Schemas::UserInput.permitted_params
# => [:name, :email]
# Handles nested objects and arrays:
# [:title, { tags: [] }, { address: [:street, :city] }]
Use the controller helper:
class Api::V1::UsersController < ActionController::API
include OpenapiRuby::ControllerHelpers
def create
user = User.new(openapi_permit(Schemas::UserInput))
# ...
end
end
Works with ActionPolicy — use permitted_params inside your policy's params_filter block.
Component Generator
rails generate openapi_ruby:component User schemas
rails generate openapi_ruby:component BearerAuth security_schemes
Testing with RSpec
# spec/openapi_helper.rb
require "openapi_ruby/rspec"
RSpec supports two DSL styles. Both generate the same OpenAPI spec and validate responses (and requests) against it.
Style 1: path / run_test!
Schema definition and test execution are interleaved. Each response block uses let values and run_test! to send the request inline:
# spec/requests/users_spec.rb
require "openapi_helper"
RSpec.describe "Users API", type: :openapi do
path "/api/v1/users" do
get "List users" do
"Users"
operationId "listUsers"
produces "application/json"
response 200, "returns all users" do
schema type: :array, items: Schemas::User
run_test! do
expect(JSON.parse(response.body).length).to be > 0
end
end
end
post "Create a user" do
"Users"
consumes "application/json"
request_body required: true, content: {
"application/json" => { schema: Schemas::UserInput }
}
response 201, "user created" do
schema Schemas::User
let(:request_body) { { name: "Jane", email: "jane@example.com" } }
run_test!
end
response 422, "validation errors" do
schema Schemas::ValidationErrors
let(:request_body) { { name: "" } }
run_test!
end
end
end
path "/api/v1/users/{id}" do
parameter name: :id, in: :path, schema: { type: :integer }, required: true
get "Get a user" do
response 200, "user found" do
schema Schemas::User
let(:id) { User.create!(name: "Jane", email: "jane@example.com").id }
run_test!
end
response 404, "not found" do
let(:id) { 0 }
run_test!
end
end
end
end
Style 2: api_path / assert_api_response
Schema definition at the top, normal RSpec examples underneath. Mirrors the Minitest DSL and is useful when you want basic schema validation separated from detailed edge-case tests:
require "openapi_helper"
RSpec.describe "Users API", type: :openapi do
openapi_schema :public_api
api_path "/api/v1/users" do
get "List users" do
"Users"
produces "application/json"
response 200, "returns all users" do
schema type: :array, items: Schemas::User
end
end
post "Create a user" do
consumes "application/json"
request_body required: true, content: {
"application/json" => { schema: Schemas::UserInput }
}
response 201, "user created" do
schema Schemas::User
end
response 422, "validation errors" do
schema Schemas::ValidationErrors
end
end
end
# Normal RSpec examples
it "returns all users" do
User.create!(name: "Jane", email: "jane@example.com")
assert_api_response :get, 200 do
expect(parsed_body.length).to eq(1)
end
end
it "creates a user" do
assert_api_response :post, 201, body: { name: "Jane", email: "jane@example.com" } do
expect(parsed_body["name"]).to eq("Jane")
end
end
end
assert_api_response accepts params:, headers:, body:, and path_params: keyword arguments. It validates the response status and body schema automatically, then yields to the block for additional expectations.
DSL Reference
| Method | Level | Description |
|---|---|---|
path(template, &block) |
Top | Define an API path (style 1) |
api_path(template, &block) |
Top | Define an API path (style 2) |
openapi_schema(name) |
Top | Set the schema name (style 2) |
get/post/put/patch/delete(summary, &block) |
Path | Define an operation |
tags(*tags) |
Operation | Tag the operation |
operationId(id) |
Operation | Set operation ID |
description(text) |
Operation | Operation description |
deprecated(bool) |
Operation | Mark as deprecated |
consumes(*types) |
Operation | Request content types |
produces(*types) |
Operation | Response content types |
security(schemes) |
Operation | Security requirements |
parameter(name:, in:, schema:, **opts) |
Path/Operation | Define a parameter |
request_body(required:, content:) |
Operation | Define request body |
response(status, description, &block) |
Operation | Define expected response |
schema(definition) |
Response | Response body schema |
header(name, schema:, **opts) |
Response | Response header |
run_test!(&block) |
Response | Execute request and validate (style 1) |
assert_api_response(method, status, **opts, &block) |
Example | Execute request and validate (style 2) |
parsed_body |
Example | Parsed JSON response body |
Testing with Minitest
# test/test_helper.rb
require "openapi_ruby/minitest"
# test/integration/users_test.rb
require "test_helper"
class UsersApiTest < ActionDispatch::IntegrationTest
include OpenapiRuby::Adapters::Minitest::DSL
openapi_schema :public_api
api_path "/api/v1/users" do
get "List users" do
"Users"
produces "application/json"
response 200, "returns all users" do
schema type: :array, items: Schemas::User
end
end
post "Create a user" do
consumes "application/json"
request_body required: true, content: {
"application/json" => { schema: Schemas::UserInput }
}
response 201, "user created" do
schema Schemas::User
end
end
end
test "GET /api/v1/users returns users" do
User.create!(name: "Jane", email: "jane@example.com")
assert_api_response :get, 200 do
assert_equal 1, parsed_body.length
end
end
test "POST /api/v1/users creates a user" do
assert_api_response :post, 201, body: { name: "Jane", email: "jane@example.com" } do
assert_equal "Jane", parsed_body["name"]
end
end
end
Spec Generation
Generate OpenAPI spec files without running tests:
rake openapi_ruby:generate
This loads spec/test files to collect API definitions and writes schemas without running any tests. It auto-detects the test framework, or you can set FRAMEWORK=rspec, FRAMEWORK=minitest, or FRAMEWORK=hybrid. Custom patterns: PATTERN="packs/*/spec/**/*_spec.rb".
Loading a test file normally is enough to run it: rails/test_help requires active_support/testing/autorun, and rspec/autorun does the equivalent — both register an at_exit hook that runs the suite. The generated script therefore installs OpenapiRuby::Generator::AutorunSuppressor before requiring anything of yours, so the hook is never registered. Generation stays a load-only operation no matter how your helpers are wired.
Schemas are only written by the rake task — running tests (bundle exec rspec, rails test) does not generate or overwrite schema files. This prevents partial schema overwrites when running a subset of specs.
No database required
The document is built from your declarations, never from the database — but rails/test_help verifies the test schema at require time (maintain_test_schema!), and many hand-written helpers add ActiveRecord::Migration.check_all_pending!. Both open a connection, which would make a database a hard requirement for generating a document that doesn't need one.
Generation stubs both out, so rake openapi_ruby:generate runs with no database available. Nothing else about your helper changes, and the stubs exist only inside the generation subprocess — normal test runs still verify the schema as usual.
Only the schema check is skipped. A connection is still available if your declarations genuinely need one (an enum built from a query at load time, say); such a suite needs a database either way.
Making generation cheaper (optional)
Generation only needs your path / api_path declarations to register. Booting the full test framework and loading fixtures is dead weight, and on a large suite it dominates the runtime.
Guard that setup with OpenapiRuby.schema_generating?, which returns true only in the rake task's subprocess (it sets OPENAPI_RUBY_GENERATING=true):
# test/test_helper.rb
require "minitest/rails" # keep the spec DSL if your api_path classes use describe/it/let
return if OpenapiRuby.
require "rails/test_help"
# ...other test-time setup...
This is purely an optimization — generation is already correct and database-free without it. Reach for it only if generation is slow enough to bother you.
When guarding backfires
The guard skips a require, so anything that require defines is gone for the whole generation run. That is fine for setup your files only touch while running, and fatal for anything they touch while loading — a file that fails to load contributes no declarations, and generation fails outright.
Loading a spec/test file executes its class body, so these all break under a guard:
fixtures :all—fixturesis undefined withoutrails/test_helpinclude Devise::Test::IntegrationHelpers— needsrspec/rails(orrails/test_help) already loadedit_behaves_like "..."/include_examplesat the top level — needs the shared examples your helper loaded
Two ways out, and they compose:
-
Narrow
PATTERNto just the files carryingpath/api_pathdeclarations, so the files with load-time dependencies are never loaded:PATTERN="test/integration/api/**/*_test.rb" rake openapi_ruby:generate -
Leave that helper unguarded. A helper whose constants are referenced at load time across the suite is often not worth guarding — you would be trading a working generation run for a faster one. Guarding is optional per helper; guard
test/test_helper.rband leavespec/rails_helper.rbalone if that is what your suite needs.
Suites using FactoryBot rather than fixtures, and keeping helper includes inside before blocks, tend not to hit any of this.
How a request finds its api_path (Style 2)
Style 2 separates the api_path declaration from the request that exercises
it, so assert_api_response has to match the request back to a declaration. It
narrows the declared paths by, in order:
- the verb — only paths declaring it stay in
- the path params — a path needing
{project_id}is out if none was supplied, and a path is out if it doesn't use every key given inpath_params: - the expected status —
assert_api_response :put, 422skips paths that don't declare a 422 for that verb - how many supplied keys the path can explain, as either one of its own path params or a parameter declared on the operation
That resolves a collection path against a member path, nested resources, and sibling paths distinguished by status. It cannot resolve paths that agree on all four:
api_path "/timers/{id}" { put("Update") { response(200, "ok") } }
api_path "/timers/{id}/start" { put("Start") { response(200, "ok") } }
api_path "/timers/{id}/stop" { put("Stop") { response(200, "ok") } }
Nothing at the call site tells those apart, so that raises
OpenapiRuby::AmbiguousApiPath naming the candidates rather than silently
picking the first and validating against the wrong response schema. Two ways to
resolve it. Name the path on the request:
assert_api_response :put, 200, path_params: {id: timer.id}, api_path: "/timers/{id}/start"
Or, in RSpec, declare each path in its own example group — a nested describe
only sees paths declared at or above it:
describe "start" do
api_path("/timers/{id}/start") { put("Start") { response(200, "ok") } }
it { assert_api_response :put, 200, path_params: {id: timer.id} }
end
describe "stop" do
api_path("/timers/{id}/stop") { put("Stop") { response(200, "ok") } }
it { assert_api_response :put, 200, path_params: {id: timer.id} }
end
To require one path per test class regardless, switch on:
config.single_api_path_per_class = true
api_path then raises OpenapiRuby::MultipleApiPaths as soon as a class
declares a second path. Off by default.
Migrating from RSpec to Minitest (or vice versa)
When both spec/spec_helper.rb and test/test_helper.rb are present, the rake task auto-selects FRAMEWORK=hybrid — it requires both adapters and loads both glob patterns (spec/**/*_spec.rb,test/**/*_test.rb) into one process. Style 1 path(...) and Style 2 api_path(...) definitions register into the same MetadataStore, so a single schema file holds paths contributed by either DSL.
Here the guards described above carry more weight: without them both test frameworks wire themselves into Rails' lazy-load hooks in the same process. Guard what you can — but the "When guarding backfires" rules still apply, so a helper your suite leans on at load time stays unguarded even in hybrid mode.
# test/test_helper.rb
unless OpenapiRuby.
require "rails/test_help"
# ...other test-time setup...
end
# spec/rails_helper.rb
unless OpenapiRuby.
require "rspec/rails"
# ...other spec-time setup...
end
OpenapiRuby.schema_generating? returns true when the rake task launched the current process (it sets OPENAPI_RUBY_GENERATING=true in the subprocess). With the guards in place, neither test framework boots its full Rails integration during generation — only the DSL needs to be live for api_path / path to register.
Once the migration completes and only one test framework remains, the rake task auto-detects that framework, and the guard goes back to being a pure optimization.
Host Frameworks
Rails picks up its wiring from the engine. Every other host makes the same calls itself — the gem only ever needs a Rack middleware stack, a way to mount a Rack app, and rack-test.
| Rails | Hanami | Sinatra / Roda / bare Rack | |
|---|---|---|---|
| Test DSL + generation | ✅ | ✅ | ✅ |
| Runtime validation middleware | automatic | one call | one call |
| Schema + Swagger UI endpoints | mount OpenapiRuby::Engine |
mount OpenapiRuby::RackApp |
map/run OpenapiRuby::RackApp |
openapi_ruby:install / :component generators |
✅ | — | — |
openapi_permit strong params |
✅ | — | — |
Versions covered by CI: Rails 7.0–8.0, Hanami 2.3 and 3.0, Sinatra 3.2 and 4.2. Working reference apps live in spec/dummy, spec/hanami_dummy, and spec/sinatra_dummy.
Hanami
1. Configure. Anywhere that loads before your app class — config/openapi_ruby.rb is a natural home:
require "openapi_ruby/hanami"
OpenapiRuby.configure do |config|
config.schemas = {
public_api: {
info: { title: "My API", version: "v1" },
servers: [{ url: "/api/v1" }],
prefix: "/api/v1" # scopes the validation middleware to the API
}
}
end
Components default to config/api_components/ on Hanami instead of app/api_components/. Zeitwerk owns everything under app/ and expects app/api_components/schemas/user.rb to define MyApp::ApiComponents::Schemas::User, while the component loader requires the file directly — which loads fine in tests and fails on eager load in production. Keeping components outside the autoload roots avoids the clash; openapi_ruby warns if component_paths points inside app/.
2. Install the middleware (only needed for runtime validation) in config/app.rb:
require "hanami"
require_relative "openapi_ruby"
module MyApp
class App < Hanami::App
# Declared before :body_parser so the validation middleware reads and
# rewinds the request body first.
OpenapiRuby::Hanami.install_middleware!(config)
config.middleware.use :body_parser, :json
end
end
3. Mount the docs endpoints in config/routes.rb:
module MyApp
class Routes < Hanami::Routes
mount OpenapiRuby::RackApp, at: "/api-docs"
end
end
For request specs, require "openapi_ruby/rspec" wires rack-test into type: :openapi example groups and points it at Hanami.app:
# spec/spec_helper.rb
ENV["HANAMI_ENV"] ||= "test"
require "hanami/prepare"
require "openapi_ruby/rspec"
Define let(:app) in a group to drive a slice instead of the whole app.
Sinatra, Roda, and bare Rack
Nothing here is Sinatra-specific — it is the same three steps against a plain Rack app.
1. Configure, and say where components live. There is no autoload convention to infer one from:
# config/openapi_ruby.rb
require "openapi_ruby"
OpenapiRuby.configure do |config|
config.schemas = {
public_api: {
info: { title: "My API", version: "v1" },
servers: [{ url: "/api/v1" }],
prefix: "/api/v1"
}
}
config.component_paths = ["api_components"]
end
2. Install the middleware onto the app's stack. Installer#install! takes anything that responds to use, which a Sinatra::Base subclass does:
class App < Sinatra::Base
OpenapiRuby::Middleware::Installer.install!(self, root: __dir__)
# ... routes
end
For Roda or bare Rack, hand it the builder instead — OpenapiRuby::Middleware::Installer.install!(builder, root: __dir__) inside Rack::Builder.new { ... }.
3. Mount the docs endpoints in config.ru:
require_relative "app"
map "/api-docs" do
run OpenapiRuby::RackApp
end
map "/" do
run App
end
For request specs, require "openapi_ruby/rspec" includes rack-test for you; naming the app is the only wiring left, since no Rack host has a convention for which app is under test:
# spec/spec_helper.rb
ENV["APP_ENV"] ||= "test"
require_relative "../app"
require "openapi_ruby/rspec"
module AppUnderTest
def app
App
end
end
RSpec.configure do |config|
config.include AppUnderTest, type: :openapi
end
Minitest is the same shape — including the DSL brings rack-test with it, and the class defines app:
class ApiTest < Minitest::Test
include OpenapiRuby::Adapters::Minitest::DSL
def app
App
end
end
Getting 403 "Host not permitted"? Sinatra only relaxes its host authorization outside
development, and rack-test sendsHost: example.org. SetAPP_ENV=test(orRACK_ENV=test) in your test helper — the snippet above does.
Specs and generation on every host
Specs are written identically regardless of host, in either DSL style:
RSpec.describe "Posts API", type: :openapi do
openapi_schema :public_api
api_path "/posts" do
get "List posts" do
"Posts"
produces "application/json"
response 200, "returns posts" do
schema type: :array, items: { "$ref" => "#/components/schemas/Post" }
end
end
end
it "returns all posts" do
assert_api_response :get, 200 do
expect(parsed_body.length).to eq(2)
end
end
end
The Rails engine loads the rake task on its own. Elsewhere, add it to your Rakefile:
require "openapi_ruby/rake_tasks"
bundle exec rake openapi_ruby:generate then behaves as it does on Rails. It detects the host and sets the environment variable that host reads — RAILS_ENV, HANAMI_ENV, or APP_ENV/RACK_ENV — for the generation subprocess.
Runtime Middleware
Validate requests and responses against your OpenAPI spec at runtime:
OpenapiRuby.configure do |config|
config.request_validation = :enabled # :enabled, :disabled, :warn_only
config.response_validation = :enabled
end
The middleware validates:
- Requests: parameter types, required parameters, request body schema (required fields, types, constraints like
minLength), content types - Responses: body schema with full
$refresolution, required fields, types
Invalid requests return 400 with details. Invalid responses return 500. In :warn_only mode, validation errors are logged but requests pass through.
Strict Mode
Strict mode can be enabled per-schema to return 404 for undocumented paths:
config.schemas = {
public_api: {
info: { title: "My API", version: "v1" },
strict_mode: true # 404 for undocumented paths
}
}
Swagger UI
Mount the engine to expose the schema endpoints:
# config/routes.rb
mount OpenapiRuby::Engine => "/api-docs"
Schema files are served at /api-docs/schemas/:name. On any other host, mount OpenapiRuby::RackApp instead — see Host Frameworks.
To also serve the interactive Swagger UI at the mount root, opt in:
OpenapiRuby.configure do |config|
config.ui_enabled = true
end
Then visit /api-docs for the UI. When ui_enabled is false (the default), /api-docs returns 404 and only the schema endpoints are served — useful when downstream tooling needs the schema but you don't want to expose an interactive explorer.
License
MIT