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