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
endSponsored by:
A fair source software licensing and distribution API.
__
Links:
Add this line to your application's Gemfile:
gem 'typed_params'And then execute:
$ bundleOr install it yourself as:
$ gem install typed_paramstyped_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
}
endTo 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}_paramstyped_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(typed_params[:user])
if user.save
render_created user, location: v1_user_url(user)
else
render_unprocessable_resource user
end
end
endTo 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
endThe 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
endAs 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
endBy 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
endSchemas 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
endTypedParams.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
endWhen 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
}
endThe TypedParams::InvalidParameterError error object has the following attributes:
#message- the error message, e.g.type mismatch (received string expected integer).#path- aPathobject with a pointer to the invalid parameter.#source- either:paramsor: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}"
}
endThe TypedParams::UnpermittedParameterError error object has the following attributes:
#message- the error message, e.g.unpermitted parameter.#path- aPathobject with a pointer to the unpermitted parameter.#source- either:paramsor: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:ifand:unless:as:alias:noop:coerce:allow_blank:allow_nil:allow_non_scalars:nilify_blanks:inclusion:exclusion:format:length:depth:transform:validate:polymorphic
The parameter's key.
param :fooThis 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: :stringThis 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
# ...
endBy 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: trueBy 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_idIn 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: trueBy 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: trueThe default is false.
The parameter can be #blank?.
param :title, type: :string, allow_blank: trueBy default, blank params are rejected with a TypedParams::InvalidParameterError
error.
The parameter can be #nil?.
param :tag, type: :string, allow_nil: trueBy default, nil params are rejected with a TypedParams::InvalidParameterError
error.
Only applicable to the :hash and :array types, and their subtypes. Allow
non-scalar values in a :hash or :array parameter.
# Allow unlimited nesting
param :metadata, type: :hash, allow_non_scalars: true
# Disallow non-scalars
param :data, type: :hash, allow_non_scalars: falseBy default, non-scalar parameters are rejected with a TypedParams::InvalidParameterError
error. Use :depth to control maximum depth.
Scalar types can be found under Types.
Automatically convert #blank? values to nil.
param :phone_number, type: :string, nilify_blanks: trueBy 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 }The parameter must be a certain depth.
# Allow nested objects up to 2 levels deep
param :config, type: :hash, depth: { maximum: 2 }
# Allow nested arrays up to 3 levels deep
param :data, type: :array, depth: { maximum: 3 }Only applicable for :hash and :array types. Automatically sets allow_non_scalars: true.
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
endType :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
endType :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
param :limited_nesting, type: :hash, depth: { maximum: 2 }Type :any. Defines a parameter that is not type checked.
param :anything_goes, type: :anyYou 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: 'json@smith.example',
# password: '7c84241a1102',
# team_id: '1',
# }
puts user_meta
# => { affilate_id: 'e805' }
end
endYou 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: 'json@smith.example',
# password: '7c84241a1102',
# team_id: '1',
# }
# }
end
endYou may register custom formatters that can be utilized in your schemas.
Each formatter consists of, at minimum, a transform: lambda, accepting a
params hash as well as optional controller: and schema: keywords, and
returning the formatted params.
For more usage examples, see the default formatters.
TypedParams::Formatters.register(:strong_params,
transform: -> (params, controller:) {
wrapper = controller.controller_name.singularize.to_sym
unwrapped = params[wrapper]
ActionController::Parameters.new(unwrapped).permit!
},
)typed_params {
format :strong_params
param :user, type: :hash do
param :email, type: :string, format: { with: /@/ }
param :password, type: :string
end
}
def create
puts user_params
# => #<ActionController::Parameters
# {"email"=>"json@smith.example","password"=>"7c84241a1102"}
# permitted: true
# >
endIf 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.