We’ve been using FieldMapper with ActiveModel::Validations for robust param validation in our Rack/Rails apps with great results.
Sometimes you’ve gotta reach for the big guns.
When Strong Params Aren’t Strong Enough
Though the FieldMapper gem is capable of doing a lot more, we’ve been getting mileage out of it for param validation.
Warning: This doesn’t pass DHH’s bullshit meter for the typical case. Only reach for a solution like this on complex scenarios where you need to go to 11. Stick with strong params for lightweight use cases.
Benefits
- Simplifies your controllers
- Isolates concerns
- Handles complex use cases
Scenario
An API endpoint that accepts params for the following objects:
Star
- name # String 2..30 chars *required
- movies # Array<Movie>
- lines # Array<String> 4 or less (2..100 chars per line)
Movies
- name # String 2..50 chars *required
- gross # Money
Solution
1. Require dependencies
require "field_mapper"
require "active_model"
2. Define a plat for MovieParams
Plat definitions are admittedly the ugliest bit. In an actual project you’d have these tucked away in lib
or app/plats
… so not too terrible in practice.
class MovieParams < FieldMapper::Standard::Plat
include ActiveModel::Validations
# declare fields
field :name, type: String
field :gross, type: Money, default: "$0.00 USD"
# declare validators after fields
validates :name,
presence: true,
length: { allow_blank: true, minimum: 2, maximum: 50 }
end
3. Define a plat for StarParams
class StarParams < FieldMapper::Standard::Plat
include ActiveModel::Validations
# declare fields
field :name, type: String
field :movies, type: FieldMapper::Types::List[MovieParams], default: []
field :lines, type: FieldMapper::Types::List[String], default: []
def line(minmax)
lines.public_send(minmax) do |a, b|
a.to_s.length <=> b.to_s.length
end
end
# declare validators after fields
validates :name,
presence: true,
length: { allow_blank: true, minimum: 2, maximum: 30 }
validates :lines, length: { maximum: 4, message: "too many (maximum is 4)" }
validate do |params|
if params.line(:min).to_s.length < 2
params.errors[:lines] << "are too short (min is 2 characters)"
end
end
validate do |params|
if params.line(:max).to_s.length > 100
params.errors[:lines] << "are too long (max is 100 characters)"
end
end
validate do |params|
params.movies.each do |movie|
if !movie.valid?
params.errors[:movies].concat movie.errors.full_messages
end
end
end
end
4. Define some query string params
This part isn’t strictly necessary since your framework does all of this for you. I just wanted to show something that will run in the console.
require "rack"
require "active_support/all"
readable_params = {
star: {
name: "Arnold",
movies: [{ name: "The Terminator", gross: "$38,371,200 USD" }],
lines: ["I'll be back."]
}
}
query = readable_params.to_query # => star[lines][]=I'll be back.&star[movies][][gross]=$38,371,200 USD&star[movies][][name]=The Terminator&star[name]=Arnold
params = Rack::Utils.parse_nested_query(query)
5. Validate params in your controller
Controllers clean up nicely because plats are doing all of the param wrangling.
class SomeController < ApplicationController
before_action :validate_params, only: [:some_action]
def some_action
# work with: permitted_params ...
end
protected
attr_reader :permitted_params
def validate_params
plat = StarParams.new(params["star"])
if plat.valid?
@permitted_params = plat.to_hash(include_meta: false)
else
render json: { errors: plat.errors.full_messages }
end
end
end
6. How about more complex params
readable_params = {
star: {
name: "Arnold",
movies: [
{ name: "The Terminator", gross: "$38,371,200 USD" },
{ name: "Predator", gross: "$59,735,548 USD" },
{ name: "Total Recall", gross: "$119,412,921 USD" },
{ name: "True Lies", gross: "$146,282,411 USD" },
{ name: "Terminator 2", gross: "$204,843,345 USD" },
],
lines: [
"I'll be back.",
"I Lied.",
"Hasta la vista, baby.",
"Get to the chopper!",
]
}
}
query = readable_params.to_query # => star[lines][]=I'll be back.&star[lines][]=I Lied.&star[lines][]=Hasta la vista, baby.&star[lines][]=Get to the chopper!&star[movies][][gross]=$38,371,200 USD&star[movies][][name]=The Terminator&star[movies][][gross]=$59,735,548 USD&star[movies][][name]=Predator&star[movies][][gross]=$119,412,921 USD&star[movies][][name]=Total Recall&star[movies][][gross]=$146,282,411 USD&star[movies][][name]=True Lies&star[movies][][gross]=$204,843,345 USD&star[movies][][name]=Terminator 2&star[name]=Arnold
params = Rack::Utils.parse_nested_query(query)
plat = StarParams.new(params["star"])
plat.valid? # => true
7. Let’s pump in lots of invalid data
readable_params = {
star: {
name: "A",
movies: [
{ name: "The Terminator", gross: "$38,371,200 USD" },
{ gross: "$59,735,548 USD" },
{ name: "Total Recall", gross: "$119,412,921 USD" },
{ name: "True Lies", gross: "$146,282,411 USD" },
{ name: "Terminator 2", gross: "$204,843,345 USD" },
],
lines: [
"I",
"I Lied.",
"Hasta la vista, baby.",
"Get to the chopper!",
"First I'm gonna use you as a human shield, then I gonna take that chisel and kill the guard with it. Then I was thinking about breaking your neck."
]
}
}
query = readable_params.to_query
params = Rack::Utils.parse_nested_query(query)
plat = StarParams.new(params["star"])
plat.valid? # => false
plat.errors.full_messages # => ["Name is too short (minimum is 2 characters)", "Lines too many (maximum is 4)", "Lines are too short (min is 2 characters)", "Lines are too long (max is 100 characters)", "Movies Name can't be blank"]
FieldMapper is doing some heavy lifting for us. We’re also leveraging ActiveRecord::Validations quite a bit. Let’s see strong parameters do that.
Note: You could likely do something similar with tabeless models.
comments powered by Disqus