0.0
The project is in a healthy, maintained state
A unified OpenAPI toolkit for Rails and Hanami 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.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

 Project Readme

openapi_ruby

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 — configuration
  • spec/openapi_helper.rb or test/openapi_helper.rb — test helper
  • app/api_components/ — directory for schema components
  • openapi/ — 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

Parameter names

The same setting covers parameter names, so parameters match the component keys instead of contradicting them. Declare them in snake_case — that keeps let(:page_size) and params: {page_size: 20} idiomatic Ruby — and the camelCased name is what reaches both the document and the request:

api_path "/users/{user_id}" do
  parameter name: :user_id, in: :path, schema: { type: :integer }

  get "List posts" do
    parameter name: :page_size, in: :query, schema: { type: :integer }
    # ...
  end
end
paths:
  /users/{userId}:            # template variables travel with the parameter names
    parameters:
      - name: userId
        in: path
    get:
      parameters:
        - name: pageSize
          in: query

The test DSL sends what the document promises: GET /users/7?pageSize=20. Header parameters keep their declared spelling — HTTP header names are conventionally hyphenated and matched case-insensitively, so camelizing them would rename headers nobody asked to rename.

If your API takes snake_case parameters, set config.camelize_keys = false (or keep the parameter names camelCased in the declaration, which passes through unchanged either way).

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

Files land in the first entry of config.component_paths, so the generator follows whatever the loader reads.

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
      tags "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
      tags "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
      tags "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
      tags "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.schema_generating?

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 — fixtures is undefined without rails/test_help
  • include Devise::Test::IntegrationHelpers — needs rspec/rails (or rails/test_help) already loaded
  • it_behaves_like "..." / include_examples at the top level — needs the shared examples your helper loaded

Two ways out, and they compose:

  1. Narrow PATTERN to just the files carrying path / api_path declarations, so the files with load-time dependencies are never loaded:

    PATTERN="test/integration/api/**/*_test.rb" rake openapi_ruby:generate
  2. 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.rb and leave spec/rails_helper.rb alone 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:

  1. the verb — only paths declaring it stay in
  2. 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 in path_params:
  3. the expected status — assert_api_response :put, 422 skips paths that don't declare a 422 for that verb
  4. 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.schema_generating?
  require "rails/test_help"
  # ...other test-time setup...
end
# spec/rails_helper.rb
unless OpenapiRuby.schema_generating?
  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: Ruby 3.2–4.0, 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 sends Host: example.org. Set APP_ENV=test (or RACK_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
      tags "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 $ref resolution, 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