typed_params
is an alternative to Rails strong parameters for controller params,
offering an intuitive DSL for defining structured and strongly-typed controller
parameter schemas for Rails APIs.
This gem was extracted from Keygen and is being used in production to serve millions of API requests per day.
class UsersController < ApplicationController
include TypedParams::Controller
rescue_from TypedParams::InvalidParameterError, with: -> err {
render_bad_request err.message, source: err.path.to_s
}
typed_params {
param :first_name, type: :string, optional: true
param :last_name, type: :string, optional: true
param :email, type: :string
param :password, type: :string
}
def create
user = User.new(user_params)
if user.save
render_created user, location: v1_user_url(user)
else
render_unprocessable_resource user
end
end
end
Sponsored by:
A fair source software licensing and distribution API.
__
Links:
Add this line to your application's Gemfile
:
gem 'typed_params'
And then execute:
$ bundle
Or install it yourself as:
$ gem install typed_params
typed_params
supports Ruby 3.1 and above. We encourage you to upgrade if you're
on an older version. Ruby 3 provides a lot of great features, like pattern matching and
a new shorthand hash syntax.
You can find the documentation on RubyDoc.
We're working on improving the docs.
- An intuitive DSL — a breath of fresh air coming from strong parameters.
- Define structured, strongly-typed parameter schemas for controllers.
- Reuse schemas across controllers by defining named schemas.
- Run validations on params, similar to active model validations.
- Run transforms on params before they hit your controller.
- Support formatters such as JSON:API.
typed_params
can be used to define a parameter schema per-action
on controllers.
To start, include the controller module:
class ApplicationController < ActionController::API
include TypedParams::Controller
rescue_from TypedParams::InvalidParameterError, with: -> err {
render_bad_request err.message, source: err.path.to_s
}
end
To define a parameter schema, you can use the .typed_params
method.
These parameters will be pulled from the request body. It accepts a
block containing the schema definition, as well as options.
The parameters will be available inside of the controller action with the following methods:
#{controller_name.singularize}_params
typed_params
class UsersController < ApplicationController
typed_params {
param :user, type: :hash do
param :first_name, type: :string, optional: true
param :last_name, type: :string, optional: true
param :email, type: :string
param :password, type: :string
param :roles, type: :array, if: :admin? do
items type: :string
end
end
}
def create
user = User.new(user_params)
if user.save
render_created user, location: v1_user_url(user)
else
render_unprocessable_resource user
end
end
end
To define a query schema, you can use the .typed_query
method. These
parameters will be pulled from the request query parameters. It
accepts a block containing the schema definition.
The parameters will be available inside of the controller action with the following methods:
#{controller_name.singularize}_query
#typed_query
class PostsController < ApplicationController
typed_query {
param :limit, type: :integer, coerce: true, allow_nil: true, optional: true
param :page, type: :integer, coerce: true, allow_nil: true, optional: true
}
def index
posts = Post.paginate(
post_query.fetch(:limit, 10),
post_query.fetch(:page, 1),
)
render_ok posts
end
end
The easiest way to define a schema is by decorating a specific controller action,
which we exemplified above. You can use .typed_params
or .typed_query
to
decorate a controller action.
class PostsController < ApplicationController
typed_params {
param :author_id, type: :integer
param :title, type: :string, length: { within: 10..80 }
param :content, type: :string, length: { minimum: 100 }
param :published_at, type: :time, optional: true, allow_nil: true
param :tag_ids, type: :array, optional: true, length: { maximum: 10 } do
items type: :integer
end
}
def create
# ...
end
end
As an alternative to decorated schemas, you can define schemas after an action has been defined.
class PostsController < ApplicationController
def create
# ...
end
typed_params on: :create do
param :author_id, type: :integer
param :title, type: :string, length: { within: 10..80 }
param :content, type: :string, length: { minimum: 100 }
param :published_at, type: :time, optional: true, allow_nil: true
param :tag_ids, type: :array, optional: true, length: { maximum: 10 } do
items type: :integer
end
end
end
By default, all root schemas are a :hash
schema. This is because both
request.request_parameters
and request.query_parameters
are hashes. Eventually,
we'd like to make that configurable,
so that you could use a top-level array schema. You can create nested schemas via
the :hash
and :array
types.
If you need to share a specific schema between multiple actions, you can define a named schema.
class PostsController < ApplicationController
typed_schema :post do
param :author_id, type: :integer
param :title, type: :string, length: { within: 10..80 }
param :content, type: :string, length: { minimum: 100 }
param :published_at, type: :time, optional: true, allow_nil: true
param :tag_ids, type: :array, optional: true, length: { maximum: 10 } do
items type: :integer
end
end
typed_params schema: :post
def create
# ...
end
typed_params schema: :post
def update
# ...
end
end
Schemas can have an optional :namespace
. This can be especially useful when
defining and sharing schemas across multiple versions of an API.
class PostsController < ApplicationController
typed_schema :post, namespace: :v1 do
param :title, type: :string, length: { within: 10..80 }
param :content, type: :string, length: { minimum: 100 }
param :author_id, type: :integer
end
typed_params schema: %i[v1 post]
def create
# ...
end
typed_params schema: %i[v1 post]
def update
# ...
end
end
TypedParams.configure do |config|
# Ignore nil params that are marked optional and non-nil in the schema.
#
# For example, given the following schema:
#
# typed_params {
# param :optional_key, type: :string, optional: true
# param :required_key, type: :string
# }
#
# And the following curl request:
#
# curl -X POST http://localhost:3000 -d '{"optional_key":null,"required_key":"value"}'
#
# Within the controller, the params would be:
#
# puts typed_params # => { required_key: 'value' }
#
config.ignore_nil_optionals = true
# Key transformation applied to the parameters after validation.
#
# One of:
#
# - :underscore
# - :camel
# - :lower_camel
# - :dash
# - nil
#
# For example, given the following schema:
#
# typed_params {
# param :someKey, type: :string
# }
#
# And the following curl request:
#
# curl -X POST http://localhost:3000 -d '{"someKey":"value"}'
#
# Within the controller, the params would be:
#
# puts typed_params # => { some_key: 'value' }
#
config.key_transform = :underscore
# Path transformation applied to error paths e.g. UnpermittedParameterError.
#
# One of:
#
# - :underscore
# - :camel
# - :lower_camel
# - :dash
# - nil
#
# For example, given the following schema:
#
# typed_params {
# param :parent_key, type: :hash do
# param :child_key, type: :string
# end
# }
#
# With an invalid `child_key`, the path would be:
#
# rescue_from TypedParams::UnpermittedParameterError, with: -> err {
# puts err.path.to_s # => parentKey.childKey
# }
#
config.path_transform = :lower_camel
end
When a parameter is provided, but it fails validation (e.g. a type mismatch), a
TypedParams::InvalidParameterError
error will be raised.
You can rescue this error at the controller-level like so:
class ApplicationController < ActionController::API
rescue_from TypedParams::InvalidParameterError, with: -> err {
render_bad_request "invalid parameter: #{err.message}", parameter: err.path.to_dot_notation
}
end
The TypedParams::InvalidParameterError
error object has the following attributes:
#message
- the error message, e.g.type mismatch (received string expected integer)
.#path
- aPath
object with a pointer to the invalid parameter.#source
- either:params
or:query
, depending on where the invalid parameter came from (i.e. request body vs query parameters, respectively).
By default, .typed_params
is :strict
. This means that if any unpermitted parameters
are provided, a TypedParams::UnpermittedParameterError
will be raised.
For .typed_query
, the default is non-strict. This means that any unpermitted parameters
will be ignored.
You can rescue this error at the controller-level like so:
class ApplicationController < ActionController::API
# NOTE: Should be rescued before TypedParams::InvalidParameterError
rescue_from TypedParams::UnpermittedParameterError, with: -> err {
render_bad_request "unpermitted parameter: #{err.path.to_jsonapi_pointer}"
}
end
The TypedParams::UnpermittedParameterError
error object has the following attributes:
#message
- the error message, e.g.unpermitted parameter
.#path
- aPath
object with a pointer to the unpermitted parameter.#source
- either:params
or:query
, depending on where the unpermitted parameter came from (i.e. request body vs query parameters, respectively).
It inherits from TypedParams::InvalidParameterError
.
Parameters can have validations, transforms, and more.
:key
:type
:strict
:optional
:if
and:unless
:as
:alias
:noop
:coerce
:allow_blank
:allow_nil
:allow_non_scalars
:nilify_blanks
:inclusion
:exclusion
:format
:length
:transform
:validate
:polymorphic
The parameter's key.
param :foo
This is required.
The parameter's type. Please see Types for more information. Some
types may accept a block, e.g. :hash
and :array
.
param :email, type: :string
This is required.
When true
, a TypedParams::UnpermittedParameterError
error is raised for
unpermitted parameters. When false
, unpermitted parameters are ignored.
param :user, type: :hash, strict: true do
# ...
end
By default, the entire .typed_params
schema is strict, and .typed_query
is not.
The parameter is optional. An invalid parameter error will not be raised in its absence.
param :first_name, type: :string, optional: true
By default, parameters are required.
You can define conditional parameters using :if
and :unless
. The parameter will
only be evaluated when the condition to true
.
param :role, type: :string, if: -> { admin? }
param :role, type: :string, if: :admin?
param :role, type: :string, unless: -> { guest? }
param :role, type: :string, unless: :guest?
The lambda will be evaled within the current controller context.
Apply a transformation that renames the parameter.
param :user, type: :integer, as: :user_id
typed_params # => { user_id: 1 }
In this example, the parameter would be accepted as :user
, but renamed
to :user_id
for use inside of the controller.
Allow a parameter to be provided via an alias.
param :owner_id, type: :integer, alias: :user_id
In this example, the parameter would be accepted as both :owner_id
and
:user_id
, but accessible as :owner_id
inside the controller.
The parameter is accepted but immediately thrown out.
param :foo, type: :string, noop: true
By default, this is false
.
The parameter will be coerced if its type is coercible and the parameter has a
type mismatch. The coercion can fail, e.g. :integer
to :hash
, and if it does,
a TypedParams::InvalidParameterError
will be raised.
param :age, type: :integer, coerce: true
The default is false
.
The parameter can be #blank?
.
param :title, type: :string, allow_blank: true
By default, blank params are rejected with a TypedParams::InvalidParameterError
error.
The parameter can be #nil?
.
param :tag, type: :string, allow_nil: true
By default, nil params are rejected with a TypedParams::InvalidParameterError
error.
Only applicable to the :hash
type and its subtypes. Allow non-scalar values in
a :hash
parameter. Scalar types can be found under Types.
param :metadata, type: :hash, allow_non_scalars: true
By default, non-scalar parameters are rejected with a TypedParams::InvalidParameterError
error.
Automatically convert #blank?
values to nil
.
param :phone_number, type: :string, nilify_blanks: true
By default, this is disabled.
The parameter must be included in the array or range.
param :log_level, type: :string, inclusion: { in: %w[DEBUG INFO WARN ERROR FATAL] }
param :priority, type: :integer, inclusion: { in: 0..9 }
The parameter must be excluded from the array or range.
param :custom_log_level, type: :string, exclusion: { in: %w[DEBUG INFO WARN ERROR FATAL] }
param :custom_priority, type: :integer, exclusion: { in: 0..9 }
The parameter must be a certain regular expression format.
param :first_name, type: :string, format: { with: /foo/ }
param :last_name, type: :string, format: { without: /bar/ }
The parameter must be a certain length.
param :content, type: :string, length: { minimum: 100 }
param :title, type: :string, length: { maximum: 10 }
param :tweet, type: :string, length: { within: ..160 }
param :odd, type: :string, length: { in: [2, 4, 6, 8] }
param :ten, type: :string, length: { is: 10 }
Transform the parameter using a lambda. This is commonly used to transform a parameter into a nested attributes hash or array.
param :role, type: :string, transform: -> _, name {
[:role_attributes, { name: }]
}
The lambda must accept a key (the current parameter key), and a value (the current parameter value).
The lambda must return a tuple with the new key and value.
Define a custom validation for the parameter, outside of the default validations. The can be useful for defining mutually exclusive params, or even validating that an ID exists before proceeding.
# Mutually exclusive params (i.e. either-or, not both)
param :login, type: :hash, validate: -> v { v.key?(:username) ^ v.key?(:email) } do
param :username, type: :string, optional: true
param :email, type: :string, optional: true
param :password, type: :string
end
# Assert user exists
param :user, type: :integer, validate: -> id {
User.exists?(id)
}
The lambda should accept a value and return a boolean. When the boolean
evaluates to false
, a TypedParams::InvalidParameterError
will
be raised.
To customize the error message, the lambda can raise a TypedParams::ValidationError
error:
param :invalid, type: :string, validate: -> v {
raise TypedParams::ValidationError, 'is always invalid'
}
Note: currently, this option is only utilized by the JSONAPI formatter.
Define a polymorphic parameter. Actual behavior will vary based on the formatter being used.
format :jsonapi
param :data, type: :hash do
param :relationships, type: :hash do
param :owner, type: :hash, polymorphic: true do
param :data, type: :hash do
param :type, type: :string, inclusion: { in: %w[users user] }
param :id, type: :integer
end
end
end
end
typed_params # => { owner_type: 'User', owner_id: 1 }
In this example, a polymorphic :owner
relationship is defined. When run
through the JSONAPI formatter, instead of formatting the relationship
into solely the :owner_id
key, it also includes the :owner_type
key for a polymorphic association.
You can define a set of options that will be applied to immediate children parameters (i.e. not grandchilden).
with if: :admin? do
param :referrer, type: :string, optional: true
param :role, type: :string
end
Type :string
. Defines a string parameter. Must be a String
.
Type :boolean
. Defines a boolean parameter. Must be TrueClass
or FalseClass
.
Type :integer
. Defines an integer parameter. Must be an Integer
.
Type :float
. Defines a float parameter. Must be a Float
.
Type :decimal
. Defines a decimal parameter. Must be a BigDecimal
.
Type :number
. Defines a number parameter. Must be either an Integer
, a Float
, or a BigDecimal
.
Type :symbol
. Defines a symbol parameter. Must be a Symbol
.
Type :time
. Defines a time parameter. Must be a Time
.
Type :date
. Defines a date parameter. Must be a Date
.
Type :array
. Defines an array parameter. Must be an Array
.
Arrays are a special type. They can accept a block that defines its item types, which may be a nested schema.
# array of hashes
param :endless_array, type: :array do
items type: :hash do
# ...
end
end
# array of 1 integer and 1 string
param :bounded_array, type: :array do
item type: :integer
item type: :string
end
Type :hash
. Defines a hash parameter. Must be a Hash
.
Hashes are a special type. They can accept a block that defines a nested schema.
# define a nested schema
param :parent, type: :hash do
param :child, type: :hash do
# ...
end
end
# non-schema hash
param :only_scalars, type: :hash
param :non_scalars_too, type: :hash, allow_non_scalars: true
You may register custom types that can be utilized in your schemas.
Each type consists of, at minimum, a match:
lambda. For more usage
examples, see the default types.
TypedParams.types.register(:metadata,
archetype: :hash,
match: -> value {
return false unless
value.is_a?(Hash)
# Metadata can have one layer of nested arrays/hashes
value.values.all? { |v|
case v
when Hash
v.values.none? { _1.is_a?(Array) || _1.is_a?(Hash) }
when Array
v.none? { _1.is_a?(Array) || _1.is_a?(Hash) }
else
true
end
}
},
)
Out of the box, typed_params
ships with two formatters. Formatters are
run after all validations and transforms, formatting the params from
one format to another format.
By default, no formatter is used.
You can add convenient support for JSONAPI by using the :jsonapi
format.
All request data
will be transformed into a hash, useable within models.
In addition, request meta
will be available inside of the controller
action with the following methods:
#{controller_name.singularize}_meta
#typed_meta
class UsersController < ApplicationController
typed_params {
format :jsonapi
param :data, type: :hash do
param :type, type: :string, inclusion: { in: %w[users user] }, noop: true
param :id, type: :string, noop: true
param :attributes, type: :hash do
param :first_name, type: :string, optional: true
param :last_name, type: :string, optional: true
param :email, type: :string, format: { with: /@/ }
param :password, type: :string
end
param :relationships, type: :hash do
param :team, type: :hash do
param :data, type: :hash do
param :type, type: :string, inclusion: { in: %w[teams team] }
param :id, type: :string
end
end
end
end
param :meta, type: :hash, optional: true do
param :affilate_id, type: :string, optional: true
end
}
def create
puts user_params
# => {
# first_name: 'John',
# last_name: 'Smith',
# email: '[email protected]',
# password: '7c84241a1102',
# team_id: '1',
# }
puts user_meta
# => { affilate_id: 'e805' }
end
end
You can add conventional wrapped params using the :rails
format.
class UsersController < ApplicationController
typed_params {
format :rails
param :first_name, type: :string, optional: true
param :last_name, type: :string, optional: true
param :email, type: :string, format: { with: /@/ }
param :password, type: :string
param :team_id, type: :string
}
def create
puts user_params
# => {
# user: {
# first_name: 'John',
# last_name: 'Smith',
# email: '[email protected]',
# password: '7c84241a1102',
# team_id: '1',
# }
# }
end
end
If you have an idea, or have discovered a bug, please open an issue or create a pull request.
For security issues, please see SECURITY.md
The gem is available as open source under the terms of the MIT License.