aldm
aldm

Reputation: 379

Clean way to implement validation on non-crud routes in Rails?

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

Answers (2)

max
max

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

rewritten
rewritten

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

Related Questions