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
- AI Agent Quick Reference
- OpenAPI Generation
- Doc DSL
- Schemas
- Parameter Validation And Type Coercion
- Configuration And 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
end
AI Agent Quick Reference
When generating Rails controller code with ActionSpec, use these as the canonical choices:
- put
doc { }ordoc("Summary") { }immediately above the action method and let ActionSpec infer the action name - use
{ }blocks insidedoc - prefer bang required syntax, such as
query! :id, Integerandname!: String; keeprequired: truefor compatibility or generated schemas - fold simple nested hash fields,
data: { }, orin_xxx(...)declarations into one line when they have 2 fields or fewer and no complex nesting, such asjson data: { name: String, age: Integer }orin_query(name: String, value: String) - declare body fields as
json data: { name!: String }orform data: { avatar!: File } - use
doc_dry,scope,transform,px/px_key,.schemas, andpx.sliceto keep controller actions small - rely on ActionSpec for parameter validation, type coercion, defaults, and similar contracts instead of rewriting the same parameter handling by hand
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
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
end
Provide a summary:
doc("Create user") {
form data: { name!: String }
}
def create
end
Escape hatch: bind the action explicitly when the inferred next method is not the intended action:
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
:remember_token, String
: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:.
Compatibility alternative: 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
)
(
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:
- 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:
doc(openapi: false) { }
doc_dry(:index, openapi: false)
Or inside the block:
doc {
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:
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/BooleanDateDateTimeTimeFileObject/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. transformdoes not run when the field does not successfully resolve to a value, such as when it is missing,nil, or already rejected by an earlier validation step.
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
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 asrequired: truewhenbang: false allow_blank: falsefrom presence validators unless that validator explicitly allows blank- enum values from
enum defaultdescfrom column commentspatternfrom format validatorsrangefrom numericality validatorslengthfrom 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 |
Scalar Object behaves like Hash and rejects non-hash values; 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.
error.errors.
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.[:invalid_type] = ->(_attribute, ) {
"should be coercible to #{.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.[:required] = "must be present"
config.[:invalid_type] = ->(_attribute, ) { "must be a valid #{.fetch(:expected)}" }
config.[: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: -> { "is not allowed for #{current_user}" }
json data: {
birthday!: { type: Date, error: ->(error, value) { "#{error}: #{value.inspect}" } }
}
}
AI Generation Style Guide
When using AI tools to generate Rails controller code, treat the AI Agent Quick Reference as the source of truth.
The rest of this README documents all supported forms, including compatibility alternatives such as doc(:action, ...) and required: true, but generated code should follow the quick reference unless the existing application style requires otherwise.
What Is Not Implemented Yet
- reusable
componentsgeneration $refgeneration and deduplicationdescription,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.