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 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
  • 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

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

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
      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.

One caveat if you do guard: skipping rails/test_help also means fixtures is undefined, so any test file calling fixtures :all in its class body fails to load. Point PATTERN at just the files carrying api_path declarations:

PATTERN="test/integration/api/**/*_test.rb" rake openapi_ruby:generate

Suites using FactoryBot rather than fixtures don't hit 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 stop being optional: without them both test frameworks wire themselves into Rails' lazy-load hooks in the same process.

# 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. The guard is then no longer required — but it's still worth keeping for the reasons in "Making generation cheaper" above.

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.

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