Reputation: 379
How do you implement validation on NON-CRUD routes? For example, there is a route for browsing recipes
. It accepts parameters term
(for search), sort
, page
and so on.
What is the neatest way to do this in Rails?
The most straight-forward solution would be to validate all these things using the bunch of if-else
expressions in the controller, but I'm looking for something more clean and easier for extension and maintinance.
I ended up with the following
class RequestValidation
attr_reader :validators
attr_reader :errors
def initialize
@validators = []
@errors = []
end
def add_validator(validator)
@validators << validator
end
def validate
@validators.each do |validator|
validator.validate
@errors += validator.errors
end
end
def valid?
validate
errors.empty?
end
end
Example of validator class
class SortValidator < Validator
VALID_SORT_FIELD = [:created_at, :rate, :total_time]
VALID_SORT_DIRECTIONS = [:asc, :desc]
def validate
return unless param
valid = true
begin
sort = param.to_unsafe_h
if sort.size == 1
k = sort.keys[0].to_sym
v = sort.values[0].to_sym
valid = VALID_SORT_FIELD.include?(k) && VALID_SORT_DIRECTIONS.include?(v)
else
valid = false
end
rescue => ex
valid = false
end
@errors << "Sort parameter invalid" unless valid
end
def is_valid?
!errors.empty?
end
end
So, RequestValidation
is like a builder / container class for all validators. And then, in the controller:
validation = RequestValidation.new
validation.add_validator(RecipesValidators::SortValidator.new(params[:sort]))
validation.validate
if validation.errors.empty?
This way, when new validation is required, we just need to create a new Validator class and add it to request validation.
Given that I took a pause from Rails for several years, there are doubts if this code is clean and neat and in accordance with Ruby principles.
Or is there a better way?
Thanks in advance
Upvotes: 1
Views: 639
Reputation: 102001
The solution here was never really a custom validator - instead you should create a class such as a model or form object to encapsulate the validations and data. Just because your action doesn't persist anything doesn't mean you can't use a model to represent data and buisness logic.
class RecipeSearch
include ActiveModel::Model
include ActiveModel::Attributes
attribute :query, :string
attribute :sort_by, :string
# ...
validates :query, length: { minimum: 5 }
validates :sort_by, in: ['newest', 'quickest', 'cheapest']
# ...
# @todo apply filters to Recipe and return collection
def perform
# ...
end
end
This lets you add validations and logic. And pass it to forms:
<%= form_with(model: local_assigns(:recipe_seach) || RecipeSearch.new) do |f| %>
# ...
<% end %>
Its also a misconception that searching can't be a part of CRUD. It is after all just Read with filters applied. If you really need a separate view from your normal index then by all means create a separate search action but its not really a neccissity.
class RecipesController
def index
@recipe_search = RecipeSearch.new(search_params)
@recipes = @recipe_search.perform
end
private
def search_params
params.fetch(:recipe_search, {})
.permit(:query, :sort_by)
end
end
Upvotes: 1
Reputation: 16435
Nowadays I would go with putting some contract and strongly typed struct between the controller and the database.
Something like:
# obtain an instance of RecipeSearchParamsContract
def contract
@contract ||= SomeValidationContract.new
end
# obtain an instance of a coercing struct
def input
MyStruct.new(params)
end
def query
MyQuery.new
end
def your_action
if contract.call(params).success?
render query.call(input)
end
end
So you are completely separating all the concerns. Good libraries to base your code on are dry-validator
and dry-struct
, with which you can generate a contract in the following way:
class SearchParamsContract < Dry::Validation::Contract
params do
optional(:sort).hash(max_size?: 1) do
optional(:rate).value(included_in?: %w[asc desc])
optional(:created_at).value(included_in?: %w[asc desc])
optional(:total_time).value(included_in?: %w[asc desc])
end
optional(:page).value(:integer)
optional(:term).filled(:string)
end
end
Using this, the coerced and validated struct is availble in the result itself, so you can chain all together and use structural pattern matching to get awesome and very ruby code (no rails extra stuff)
def search
case contract.call(params)
in Success(input)
@recipes = query.call(input)
else
render :error
end
end
Upvotes: 1