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
- OpenAPI Generation
- Doc DSL
docdoc_dry- DSL Inside
doc- Parameter
- Request body
openapi false- Scope
- Response
- Schemas
- Declare A Required Field
- Field Types
- Field Options
- Schemas From ActiveRecord
- Type And Boundary Matrix
- Parameter Validation And Type Coercion
- Validation Flow
- Reading Processed Values With
px -
pxis Whitelist Extraction - Errors
- Configuration And I18n
- Configuration
- I18n
- 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
endInstallation
# Gemfile
gem "action_spec"Then run:
$ bundleOpenAPI Generation
Generate an OpenAPI document from the current Rails routes and ActionSpec controller docs:
bin/rails action_spec:genBy 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
docdeclaration are included - endpoints with
openapi falseare 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
TITLEorVERSION, 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
endProvide a summary:
doc("Create user") {
form data: { name!: String }
}
def create
endYou can also bind it explicitly when you want the action name declared in place:
doc(:create, "Create user") {
form data: { name!: String }
}
def create
endOverride 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
}
endAll 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, StringBang 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
endWith 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, FileNotes:
- When multiple
body/body!,json/json!, orform/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 falseScope
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 optionalResponse declarations are stored as metadata and are emitted in OpenAPI. They do not render responses automatically at runtime.
Notes:
-
response,error, anderrorsdefaultmedia_typeto:jsonand this default is configurable. - 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!:ornickname!:mark nested object fields as required -
body!,json!, andform!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:
StringIntegerFloatBigDecimal-
:boolean/Boolean DateDateTimeTimeFile-
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 becomesnil.
-
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
Symbolor aProc.
-
px/px_key- Customize the key name used when the parameter is written into
px.
- Customize the key name used when the parameter is written into
-
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
pxand directly call controller methods such ascurrent_user. - When
validatereturnsfalseornil, the field adds aninvaliderror.
- Accepts a
-
error/error_message- Override the error message used when that field fails validation or coercion.
- Supported forms:
String-> { }->(error, value) { }
- Field-level
error/error_messagehave higher priority than globalconfig.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
endUser.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.schemasYou 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 asrequired: truewhenbang: false -
allow_blank: falsefrom presence validators unless that validator explicitly allows blank - enum values from
enum default-
descfrom column comments -
patternfrom format validators -
rangefrom numericality validators -
lengthfrom 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] -
pxitself is anActiveSupport::HashWithIndifferentAccess, and nested object values such aspx[: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
AuthorizationandHTTP_AUTHORIZATION, for example:
px.scope[:headers][:authorization]
px.scope[:headers]["Authorization"]
px.scope[:headers]["HTTP_AUTHORIZATION"]- original
paramsare 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
endThe 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: DefaultActionSpec::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 wherebin/rails action_spec:genwrites the generated OpenAPI document. -
open_api_title: Defaultnil; sets the default OpenAPIinfo.titleused bybin/rails action_spec:gen. -
open_api_version: Defaultnil; sets the default OpenAPIinfo.versionused bybin/rails action_spec:gen. -
open_api_server_url: Defaultnil; sets the default server URL emitted in the generated OpenAPI document. -
default_response_media_type: Default:json; sets the default response media type used byresponse,error, anderrorswhen 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 { }ordoc("Summary") { }; do not add the action name, and do not leave a blank line between thedocblock and the action method - use
{ }blocks insidedocas well; prefer them overdo ... 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)(preferin_xxx(...)batch declarations over multiplexxDSL lines when possible)
- use
!but notrequired: true - use
doc_dry,scope, andtransformγpx(px_key)γpx.sliceto keep controller concise - when request parameters match model declarations, prefer
.schemasto keepdocconcise
What Is Not Implemented Yet
- reusable
componentsgeneration -
$refgeneration and deduplication -
description,externalDocs,deprecated, andsecurityon 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, andcomponents.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, andnot
Contributing
Contributions / Issues are welcome.
License
The gem is available as open source under the terms of the MIT License.
