在 Rails 中对非 CRUD 路由实施验证的简洁方法?

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

您如何在非 CRUD 路由上实施验证?比如有一个浏览的路由recipes。它接受参数 term(用于搜索)、sortpage 等。

在 Rails 中执行此操作的最巧妙方法是什么?

最直接的解决方案是使用控制器中的一堆 if-else 表达式来验证所有这些东西,但我正在寻找更干净、更容易扩展和维护的东西。

我得到了以下结果

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

验证器示例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

因此,RequestValidation 就像所有验证器的构建器/容器 class。然后,在控制器中:

    validation = RequestValidation.new
    validation.add_validator(RecipesValidators::SortValidator.new(params[:sort]))

    validation.validate

    if validation.errors.empty?

这样,当需要新的验证时,我们只需要创建一个新的验证器class并将其添加到请求验证中。

鉴于我从Rails停了好几年,怀疑这段代码是否干净利落,是否符合Ruby原则。

或者有更好的方法吗?

提前致谢

现在我会在控制器和数据库之间放置一些契约和强类型结构。

类似于:

# 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

所以你完全分离了所有的关注点。 dry-validatordry-struct 是您编写代码的良好库,您可以使用它们以下列方式生成合约:

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

使用这个,强制和验证的结构在结果本身中是可用的,所以你可以将所有链接在一起并使用结构模式匹配来获得很棒的非常 ruby 代码(没有 rails 额外东西)

def search
  case contract.call(params)
  in Success(input)
    @recipes = query.call(input)
  else
    render :error
  end
end 

这里的解决方案从来都不是真正的自定义验证器 - 相反,您应该创建一个 class 例如模型或表单对象来封装验证和数据。仅仅因为您的操作不会持久化任何东西并不意味着您不能使用模型来表示数据和业务逻辑。

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

这让您可以添加验证和逻辑。并将其传递给表单:

<%= form_with(model: local_assigns(:recipe_seach) || RecipeSearch.new) do |f| %>
  # ...
<% end %>

搜索不能成为 CRUD 的一部分也是一种误解。毕竟它只是阅读并应用了过滤器。如果您真的需要一个与普通索引不同的视图,那么一定要创建一个单独的搜索操作,但这并不是必需的。

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