0.0
No release in over 3 years
A concise Rails DSL for declaring API request and response schemas.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

>= 7.0, < 9.0

Runtime

>= 7.0.0
 Project Readme

ActionSpec

Concise and Powerful API Documentation Solution for Rails. δΈ­ζ–‡

  • OpenAPI version: v3.2.0
  • Requires: Ruby 3.1+ and Rails 7.0+
  • Note: this project was implemented with Codex in about 3 hours, has not yet been manually reviewed, and has not been validated in production. It does, however, come with fairly detailed RSpec tests generated with Codex.

Table Of Contents

  1. OpenAPI Generation
  2. Doc DSL
    1. doc
    2. doc_dry
    3. DSL Inside doc
      1. Parameter
      2. Request body
      3. openapi false
      4. Scope
      5. Response
  3. Schemas
    1. Declare A Required Field
    2. Field Types
    3. Field Options
    4. Schemas From ActiveRecord
    5. Type And Boundary Matrix
  4. Parameter Validation And Type Coercion
    1. Validation Flow
    2. Reading Processed Values With px
    3. px is Whitelist Extraction
    4. Errors
  5. Configuration And I18n
    1. Configuration
    2. I18n
  6. AI Generation Style Guide

Example

class UsersController < ApplicationController
  before_action :validate_and_coerce_params!, only: :create

  doc {
    header :Authorization, String
    path :account_id, Integer
    query :locale, String, default: "zh-CN", transform: -> { it.downcase }
    query :key_number, Integer, default: -> { it + 1 }, px: :key

    form data: {
      name!: { type: String, transform: :strip },
      birthday: Date,
      admin: { type: :boolean, default: false },
      tags: [{ id: Integer, content!: { type: String, blank: false } }],
      profile: { nickname!: String }
    }

    response 200, "success", :json, data: { code: Integer, result: [Hash] }
    errors 400, "client errors", {
      invalid_params: { code: 1234, message: "parameters are invalid" },
      missing_params: { code: 1235, message: "parameters are missing" }
    }
  }
  def create
    User.find(px[:account_id]).update!(
      key: px[:key], name: px[:name],
      **px.slice(:birthday, :admin, :profile)
    )
  end
end

Installation

# Gemfile
gem "action_spec"

Then run:

$ bundle

OpenAPI Generation

Generate an OpenAPI document from the current Rails routes and ActionSpec controller docs:

bin/rails action_spec:gen

By default, this writes to:

docs/openapi.yml

Environment variables can override the default output path and document metadata:

bin/rails action_spec:gen \
  OUTPUT=docs/openapi.yml \
  TITLE="My API" \
  VERSION="2026.03" \
  SERVER_URL="https://api.example.com"

Notes:

  • only routed controller actions with a matching doc declaration are included
  • endpoints with openapi false are skipped even when routed
  • Rails paths such as /users/:id(.:format) are rendered as /users/{id}
  • parameters, request bodies, and response descriptions are generated from the current DSL support
  • if config and environment variables do not provide TITLE or VERSION, ActionSpec falls back to application-derived defaults

Doc DSL

doc

With action inferred from the next instance method:

doc {
  form data: {    # <= request body DSL
    name!: String # <= schema DSL
  }
}
def create
end

Provide a summary:

doc("Create user") {
  form data: { name!: String }
}
def create
end

You can also bind it explicitly when you want the action name declared in place:

doc(:create, "Create user") {
  form data: { name!: String }
}
def create
end

Override the default OpenAPI tag with tag:. By default, the tag comes from the routed controller_path:

doc_dry(:index, tag: "backoffice")

doc("List users", tag: "members") {
  query :status, String
}

Generated OpenAPI operations also include an operationId, built from the final tag plus the action name, for example members_index or users_create.

doc_dry

class ApplicationController < ActionController::API
  doc_dry(%i[show update destroy]) {
    path! :id, Integer
  }

  doc_dry(:index) {
    query :page, Integer, default: 1
    query :per, Integer, default: 20
  }
end

All matching dry blocks are applied before the action-specific doc.

DSL Inside doc

Parameter

header  :Authorization, String
header! :Authorization, String

path  :id, Integer
path! :id, Integer

query  :page, Integer
query! :page, Integer

cookie  :remember_token, String
cookie! :remember_token, String

Bang methods mark the field as required. For example, query! :page, Integer means the request must include page, and the value must not be nil. Blank values are still allowed unless you set blank: false.

You can also change that default globally:

ActionSpec.configure do |config|
  config.required_allow_blank = false
end

With required_allow_blank = false, required fields reject blank strings unless that field explicitly sets blank: or allow_blank:.

If you prefer not to use bang methods, you can also write required: true:

query :page, Integer, required: true
json data: {
  title: { type: String, required: true }
}

Batch declaration forms:

in_header(
  Authorization: String
)

in_path!(
  id: Integer
)

in_query(
  page: Integer,
  per: { type: Integer, default: 20 },
  locale: String
)

in_cookie(
  remember_token: String
)

in_query!(
  user_id: Integer,
  token: String
)

Request body

General form:

body :json, data: { name!: String, age: Integer }

Convenience helpers:

json data: { name!: String }
json! data: { name!: String }

form data: { file!: File, position: String }
form! data: { file!: File }

Single multipart field helper:

data :file, File

Notes:

  1. When multiple body/body!, json/json!, or form/form! declarations are used:
    • declarations with the same media type are merged
    • if multiple media types are declared, the generated OpenAPI document will emit multiple media types
    • field validation and coercion do not distinguish between media types, and always read values from Rails params

body!, json!, and form! make the root request body required at runtime. You can also write required: true on body, json, or form if you prefer not to use bang methods.

openapi false

You can also opt an action out of OpenAPI generation from either doc or doc_dry:

openapi false

Scope

Use scope when you want a grouped view that spans multiple request locations:

doc {
  scope(:user) {
    query :user_id, Integer
    form data: { name: String }
  }
  form data: { not_in_scope: String }
}

Then read it from px.scope:

px.scope[:user] # => { user_id: 1, name: "Tom" }

You can also trim custom scope buckets with compact: or compact_blank::

doc {
  scope(:search, compact: true) {
    query :page, Integer, transform: -> { nil }, px: :page_number
    query :keyword, String
  }

  scope(:filters, compact_blank: true) {
    query :q, String, transform: :strip
    query :nickname, String, transform: -> { "" }
  }
}

px.scope[:search]  # => { keyword: "rails" }
px.scope[:filters] # => { q: "ruby" }

These options only apply to the custom px.scope[:name] bucket defined by that scope, and use shallow hash compaction.

Response

response 200, desc: "success"
response 422, "validation failed"
response 200, :json, data: { code!: Integer, result: Object }

error 401, "unauthorized"
error 503, { code!: Integer, message!: String } # error data schema
error 503, { code: 1000, message: "invalid params" } # unnamed error example
error 503, invalid_params: { code: 1000, message: "invalid params" } # named error example
# declare multiple named examples in batch
errors 503, {
  invalid_params: { code: 1000, message: "invalid params" },
  network_error: { code: 1001, message: "network error" }
}
errors 503, network_error: { code: 1001 }, upstream_timeout: { code: 1002 } # braces are also optional

Response declarations are stored as metadata and are emitted in OpenAPI. They do not render responses automatically at runtime.

Notes:

  1. response, error, and errors default media_type to :json and this default is configurable.
  2. If examples are declared without an explicit schema, ActionSpec infers the response schema from the example payloads for OpenAPI generation.

Schemas

Declare A Required Field

Use ! in either place:

query! :page, Integer

json data: {
  name!: String,
  profile: {
    nickname!: String
  }
}

Meaning of !:

  • query!, path!, header!, cookie! mark the parameter itself as required
  • keys such as name!: or nickname!: mark nested object fields as required
  • body!, json!, and form! mark the root request body as required

You can also use required: true instead of bang syntax for parameters, nested fields, and the root request body.

required in ActionSpec means "present and not nil". By default it does not reject blank strings. You can keep that default, change it globally with config.required_allow_blank, or override it per field with blank: / allow_blank:.

When blank values are allowed, type coercion does not fail just because the input is an empty string. If the field can still carry a meaningful blank value, such as String, the original blank string stays in px. Otherwise, ActionSpec stores nil for that field. For example, "" stays "" for String, but becomes nil for Date.

Field Types

Scalar types currently supported by validation/coercion:

  • String
  • Integer
  • Float
  • BigDecimal
  • :boolean / Boolean
  • Date
  • DateTime
  • Time
  • File
  • Object / Hash
query :page, Integer
form data: { file: File }

Object and Nested Object forms:

json data: {
  settings: Object,
  # == settings: { type: Object }
  # == settings: { type: Hash }
  # == settings: { type: { } }

  profile: { nickname!: String },
  tags: [],
  # == tags: { type: [] }
  foo: [{ id: Integer }],

  users: {
    type: { name!: String },
    default: { name: "Tom" },
    transform: -> { { name: it[:name].downcase } }
  }
}

When you write xx: { type: ... }, the type of xx comes from type. In other words, type is a reserved schema keyword here, not a normal nested field name.

Field Options

query :page, Integer, default: 1
query :today, Date, default: -> { Time.current.to_date }
query :status, String, enum: %w[draft published]
query :score, Integer, range: { ge: 1, le: 5 }
query :slug, String, pattern: /\A[a-z\-]+\z/
query :title, String, blank: false

query :nickname, String, transform: :downcase
query :page, Integer, transform: -> { it + 1 }, px: :page_number
query :end_at, Integer, validate: -> { current_user && it >= px[:start_at] }
query :birthday, Date, error: "birthday error"
  • required

    • Marks the field as required.
    • Can replace bang syntax when you do not use name!:.
  • default

    • Default value used when the field is missing.
    • Can be a literal or -> { }.
  • enum

    • Restricts the field to values from a fixed set.
  • range

    • Numeric range constraints.
    • Available: ge / gt / le / lt
  • pattern

    • Regex constraint.
  • length

    • Length constraints.
    • Available: minimum / maximum / is
  • blank / allow_blank

    • Controls whether blank values are allowed.
    • For fields such as Date, if blank is allowed, no type coercion is applied and the value becomes nil.
  • desc

    • Used only for OpenAPI description generation.
  • example

    • Used only for generating a single OpenAPI example.
  • examples

    • Used only for generating multiple OpenAPI examples.
  • transform

    • Applies one more custom transformation to the already-coerced value.
    • Accepts a Symbol or a Proc.
  • px / px_key

    • Customize the key name used when the parameter is written into px.
  • validate

    • Accepts a Proc.
    • Runs after all parameters have finished resolving, coercion, transform, and writing into px.
    • Runs in the current controller context, so it can read px and directly call controller methods such as current_user.
    • When validate returns false or nil, the field adds an invalid error.
  • error / error_message

    • Override the error message used when that field fails validation or coercion.
    • Supported forms:
      • String
      • -> { }
      • ->(error, value) { }
    • Field-level error / error_message have higher priority than global config.error_messages.

About nested object fields

Inner field options run first, and the outer object field runs last.
In other words, nested transform / px first participate in building the object, and after the whole object has been built, the outer field receives that final object and continues processing it.
Only after the whole object tree has finished resolving, coercing, and transforming does ActionSpec enter the post-validate phase: inner field validate callbacks run first, and outer object field validate callbacks run afterwards.

json data: {
  user: {
    type: {
      name: { type: String, transform: :strip, px: :nickname }
    },
    transform: -> { { name: it[:nickname].downcase } }
  }
}

px[:user] # => { "name" => "tom" }

These options are used by OpenAPI generation:

query :page, Integer, desc: "page number", example: 1, examples: [1, 2, 3]

If an OpenAPI-facing option such as default cannot be converted into YAML, for example default: -> { ... }, it will be omitted from the generated OpenAPI document.

Schemas From ActiveRecord

If your model is an ActiveRecord::Base, you can derive an ActionSpec-friendly schema hash directly from the model:

class UsersController < ApplicationController
  doc {
    form data: User.schemas
  }
  def create
  end
end

User.schemas returns a symbol-keyed hash that can be passed directly into form data:, json data:, or body.

By default, it includes all model fields:

User.schemas

You can also limit the exported fields:

User.schemas(only: %i[name phone role])

Or exclude specific fields:

User.schemas(except: %i[phone role])

When only: and except: are used together, ActionSpec applies except: after only:.

You can also extract validators for a specific validation context:

User.schemas(on: :create)

You can also override requiredness in the exported schema:
When required: is an array, only the listed fields are treated as required, and every other exported field is treated as non-required.

User.schemas(required: true)
User.schemas(required: false)
User.schemas(required: %i[name role])

You can also merge custom schema fragments into the exported fields:

User.schemas(merge: { name: { required: false, desc: "nickname" } })

bang: defaults to true, so required fields are emitted as bang keys such as name!:. only: and except: both accept plain names or bang-style names such as phone!. If you prefer plain keys, you can pass bang: false, and required fields will be emitted as required: true instead:

User.schemas(bang: false)

ActionSpec extracts schema-relevant information from ActiveRecord / ActiveModel when available, including:

  • field type
  • requiredness, rendered either as bang keys such as name!: or as required: true when bang: false
  • allow_blank: false from presence validators unless that validator explicitly allows blank
  • enum values from enum
  • default
  • desc from column comments
  • pattern from format validators
  • range from numericality validators
  • length from length validators and string column limits

Conditional validators with if: or unless: are skipped during schema extraction, because they cannot be represented as unconditional static schema rules. Validators with on: / except_on: are skipped by default, but can be extracted by passing schemas(on: ...). The required: option overrides the requiredness of exported fields only; it does not remove other extracted constraints such as allow_blank: false. The merge: option deep-merges custom fragments into each extracted field definition.

Example output:

User.schemas
# {
#   name!: { type: String, desc: "user name", length: { maximum: 20 } },
#   phone!: { type: String, allow_blank: false, length: { maximum: 13 }, pattern: /\A1\d{10}\z/ },
#   role: { type: String, enum: %w[admin member visitor] }
# }
User.schemas(bang: false)
# { name: { type: String, required: true, desc: "user name", length: { maximum: 20 } }, ... }

Type And Boundary Matrix

Type Accepted examples Rejected examples / notes
String 12, true, "" Follows ActiveModel::Type::String, so true becomes "t"
Integer "0", "-12", "+7", 12 Rejects "12.3", "abc", ""
Float "0", "-12.5", 12, 12.5 Rejects "12.3.4", "abc"
BigDecimal "0", "-12.50", 12, 12.5 Rejects "abc"
:boolean / Boolean true, false, "1", "0", "true", "false", "yes", "no", "on", "off" Rejects ambiguous values such as "", "2", "TRUE ", "maybe"
Date "2025-10-17" Rejects invalid dates such as "2025-02-30"
DateTime "2025-10-17T12:30:00Z" Rejects invalid datetimes such as "2025-10-17 25:00:00"
Time "2025-10-17T12:30:00Z" Follows ActiveModel::Type::Time, so the date part becomes 2000-01-01
File ActionDispatch::Http::UploadedFile, Tempfile, file-like IO objects Keeps the object as-is and does not read file contents into memory
Object Hash, ActionController::Parameters, arbitrary Ruby objects Passed through for scalar Object; nested hashes use object schema resolution
[Type] arrays such as %w[1 2 3] for [Integer] Rejects non-array values, and reports item errors like tags.1
nested object { profile: { nickname: "neo" } } Rejects non-hash values, and reports nested paths like profile.nickname

Parameter Validation And Type Coercion

Validation Flow

validate_params!

Validates using the DSL, but keeps raw values in px.

before_action :validate_params!

Example:

  • request query param "page" => "2"
  • DSL says query :page, Integer
  • result: px[:page] == "2"

You can safely put this hook on a base controller. If the current action has no matching doc, ActionSpec skips validation and returns an empty px.

validate_and_coerce_params!

Validates and coerces values before exposing them on px.

before_action :validate_and_coerce_params!

Example:

  • request query param "page" => "2"
  • DSL says query :page, Integer
  • result: px[:page] == 2

This hook also skips actions without a matching doc, so it is safe to declare on a shared base controller.

Reading Processed Values With px

px stores the processed values produced by ActionSpec. With validate_params! they stay raw; with validate_and_coerce_params! they are coerced values.

Because px is still a hash, you can also use helpers such as px.slice(...) to simplify parameter access code.

px[:id]
px[:page]
px[:profile][:nickname]
px.to_h
px.scope[:user]

Grouped views live under px.scope:

px.scope[:path]
px.scope[:query]
px.scope[:body]
px.scope[:headers]
px.scope[:cookies]
px.scope[:the_scope_you_defined]

Notes:

  • every declared field from path/query/body is also flattened into the top-level px[:field]
  • px itself is an ActiveSupport::HashWithIndifferentAccess, and nested object values such as px[:profile] are also returned as indifferent hashes
  • headers and cookies stay inside their own grouped buckets; for example, px[:Authorization] is not a top-level shortcut
  • header keys are stored in lowercase dashed form, but reading remains compatible with original forms such as Authorization and HTTP_AUTHORIZATION, for example:
px.scope[:headers][:authorization]
px.scope[:headers]["Authorization"]
px.scope[:headers]["HTTP_AUTHORIZATION"]
  • original params are not mutated

px is Whitelist Extraction

json data: {
  profile: {
    nickname: String
  }
}

# request params
{
  profile: {
    nickname: "Neo",
    role: "admin"
  }
}

px[:profile] # => { "nickname" => "Neo" }

Undeclared fields are filtered out, including extra keys inside nested objects.

Errors

Validation errors are stored in ActiveModel::Errors.

When validation fails, ActionSpec raises ActionSpec::InvalidParameters:

begin
  validate_and_coerce_params!
rescue ActionSpec::InvalidParameters => error
  error.message
  error.errors.full_messages
end

The exception also keeps the full validation result on error.result and error.parameters. ActionSpec does not render a default error response for you, so each application can decide its own rescue and JSON format.

error.message is built from error.errors.full_messages.to_sentence, so it follows normal ActiveModel::Errors wording:

  • single error: "Page is required"
  • multiple errors: "Page is required and Birthday must be a valid date"
  • fallback when no detailed errors are present: "Invalid parameters"

Use error.errors when you need structured details, and error.message when you only need a single summary string.

Configuration And I18n

Configuration

ActionSpec.configure { |config|
  config.open_api_output = "docs/openapi.yml"
  config.open_api_title = "My API"
  config.open_api_version = "2026.03"
  config.open_api_server_url = "https://api.example.com"
  config.default_response_media_type = :json

  config.error_messages[:invalid_type] = ->(_attribute, options) {
    "should be coercible to #{options.fetch(:expected)}"
  }
}

Available config keys:

  • invalid_parameters_exception_class: Default ActionSpec::InvalidParameters; controls which exception class is raised when validation fails.
  • error_messages: Default {}; lets you override error messages by error type, or by attribute plus error type.
  • open_api_output: Default "docs/openapi.yml"; controls where bin/rails action_spec:gen writes the generated OpenAPI document.
  • open_api_title: Default nil; sets the default OpenAPI info.title used by bin/rails action_spec:gen.
  • open_api_version: Default nil; sets the default OpenAPI info.version used by bin/rails action_spec:gen.
  • open_api_server_url: Default nil; sets the default server URL emitted in the generated OpenAPI document.
  • default_response_media_type: Default :json; sets the default response media type used by response, error, and errors when no media type is passed explicitly.

I18n

ActionSpec uses ActiveModel::Errors, so you can override both messages and attribute names:

en:
  activemodel:
    attributes:
      action_spec/parameters:
        "profile.nickname": "Profile nickname"
    errors:
      messages:
        required: "is required"
        invalid_type: "must be a valid %{expected}"

You can also override messages per error type or per attribute in Ruby:

ActionSpec.configure { |config|
  config.error_messages[:required] = "must be present"
  config.error_messages[:invalid_type] = ->(_attribute, options) { "must be a valid #{options.fetch(:expected)}" }
  config.error_messages[:page] = {
    required: "page is mandatory"
  }
}

If you want to override one specific field directly in the DSL, use error or error_message on that field:

doc {
  query! :page, Integer, error: "choose a page first"
  query :role, String, validate: -> { false }, error_message: -> { "is not allowed for #{current_user}" }
  json data: {
    birthday!: { type: Date, error_message: ->(error, value) { "#{error}: #{value.inspect}" } }
  }
}

AI Generation Style Guide

When using AI tools to generate Rails controller code, and the change involves parameter validation, type coercion, default values, or similar parameter contracts, these conventions work well with ActionSpec:

  • use doc { } or doc("Summary") { }; do not add the action name, and do not leave a blank line between the doc block and the action method
  • use { } blocks inside doc as well; prefer them over do ... end
  • when a batch has 3 fields or fewer and does not contain nested hashes, prefer a single-line style, for example:
    • json data: { type: String, required: true }
    • in_query(name: String, value: String) (prefer in_xxx(...) batch declarations over multiple xx DSL lines when possible)
  • use ! but not required: true
  • use doc_dry, scope, and transform、px(px_key)、px.slice to keep controller concise
  • when request parameters match model declarations, prefer .schemas to keep doc concise

What Is Not Implemented Yet

  • reusable components generation
  • $ref generation and deduplication
  • description, externalDocs, deprecated, and security on operations
  • parameter-level style, explode, allowReserved, examples, and richer header/cookie serialization controls
  • request body encoding
  • multiple request/response media types beyond the current direct DSL mapping
  • response headers
  • response links
  • callbacks
  • webhooks
  • path-level shared parameters
  • top-level components.parameters, components.requestBodies, components.responses, components.headers, components.examples, components.links, components.callbacks, components.schemas, components.securitySchemes, and components.pathItems
  • top-level security
  • top-level tags
  • top-level externalDocs
  • jsonSchemaDialect
  • richer schema keywords beyond the current subset, including object-level constraints, and composition keywords such as oneOf, anyOf, allOf, and not

Contributing

Contributions / Issues are welcome.

License

The gem is available as open source under the terms of the MIT License.