Using dry-validation with Grape
UPDATE: This feature (with certain changes in syntax) will be in Grape 2.1.0.
Intro
Grape is a popular web framework for Ruby, an alternative to Rails when writing an API-only application.
dry-validation is a flexible library for implementing data validation logic, something one tends to need more as their project grows.
When an API deals with a certain entity, the shape of endpoint parameters in it tends to resemble the shape of the entity itself. And if you already have validation rules defined for it somewhere else (or intend to do that later, depending on the order the functionality is built), you might like to use the same language/library/dsl for describing the schema and rules in both cases, to be able to reuse some.
Grape comes with its own terse DSL for describing parameters which
starting with version 1.3.0 is implemented using dry-types under the
cover. But that
doesn’t make it much easier to use dry-validation
(a layer above
it). Here’s the open feature
request.
Problem statement
Our goal here will be to declare the parameters for an API endpoint
using a dry-validation
contract, have it check and coerce the
inputs, and in the case when the validation fails, send the response
describe the failure in the same format as the built-in validation
does, let’s say, for interoperability.
Here’s an example:
module Apples
class API < Grape::API
format :json
prefix :api
resource :orders do
desc 'Create new'
params do
requires :order, type: Hash do
requires :baskets, type: Array do
requires :color, type: String, values: %w[green red yellow]
optional :count, type: Integer, default: 10
end
end
end
post do
Order.create!(declared(params))
end
end
end
end
Our endpoint creates a nice big order of several baskets of apples, each from one of three varieties.
Here’s a faulty request and the response we get (for two attributes we messed up):
$ curl -H 'Content-Type: application/json' http://localhost:9292/api/orders/ \
-d '{"order": {"baskets": [{"clor": 10, "count": "red"}]}}'
{"error":"order[baskets][0][color] is missing, order[baskets][0][count] is invalid"}
The error value enumerates all the invalid attributes, printing their full paths.
Custom type
Here’s how this structure can be described with dry-rb
:
ColorString = Dry::Types['string'].constrained(included_in: %w(green red yellow))
BasketSchema = Dry::Schema.Params do
required(:color).filled(ColorString)
optional(:count).filled(Dry::Types['integer'].default(10))
end
class OrderContract < Dry::Validation::Contract
params do
required(:baskets).array(BasketSchema)
end
end
One unsatisfying approach is to try the documented way to use the
custom types
by defining self.parse
on the type. It will almost work, but there
is no way to get the name of the “wrapper” attribute (order
in our
example):
params do
requires :order, type: OrderContract
end
And Grape will prepend the wrapper’s name to the returned error
message, which seems difficult to print right when there are several
validation errors within our structure. So self.parse
seems to be
better suited for “leaf” values.
Simple delegation
Okay, referencing the contract in params
seems problematic. Let’s
drop that block entirely and do the validation inside the handler.
We create a new class which will call our contract to validate, raise an exception (our custom type) to signal failure, and in the case of success yields to the block. The error is formatted from the structure the validator returns:
ContractError = Class.new(StandardError)
class ApiContract < Dry::Validation::Contract
def validate!(value)
res = call(value)
if res.success?
yield res.to_h
else
message = res.errors.messages.map do |message|
full_name = message.path.first.to_s
full_name += "[#{message.path[1..].join('][')}]" if message.path.size > 1
"#{full_name} #{message.text}"
end.join(', ')
raise ContractError.new(message)
end
end
end
# ...
class CreateOrderContract < ApiContract
params do
required(:order).filled(OrderSchema)
end
end
# ...
class API < Grape::API
format :json
prefix :api
rescue_from ContractError do |e|
error!({error: e.message}, 400)
end
resource :orders do
desc 'Create new'
post do
CreateOrderContract.new.validate!(params) do |attrs|
order = Order.create!(attrs)
body order
end
end
end
end
This reaches our goal (correct response format). Whether to actually use Grape’s format or keep the nested structure, is user’s choice. But this way we control the response either way.
Custom DSL
The above is a little verbose, though. And it adds an extra nesting level. What if we wanted to make the usage more succinct?
Looking at how the params
block is implemented,
the class Grape::Validations::ParamsScope
applies itself to the
current endpoint by setting three “namespace stackable” attributes:
declared_params
, renamed_params
and validations
. We will use
neither, using our own attribute to store the assigned contract.
If we just add a helper to fetch the validated input, we don’t need to
convert the schema to what declared
understands. This could also be
extended later so that the contract and declared params are used
together, if needed.
We also define an error type inheriting from
Grape::Exceptions::Base
, so Grape handles it appropriately by
default, but that can still be overridden in some (or all) endpoints
using rescue_from
.
The new implementation:
module GrapeContract
class ValidationError < Grape::Exceptions::Base
# @return [Dry::Validation::MessageSet]
attr_reader :errors
def initialize(errors:, headers: nil)
@errors = errors
message = errors.messages.map ...
super(status: 400, message: message, headers: headers)
end
end
module EndpointDSL
def contract(klass = nil, &block)
unless klass
klass = Class.new(Dry::Validation::Contract, &block)
end
self.namespace_stackable(:contract, klass)
end
end
module InsideRouteHelper
def contract_params
klass = namespace_stackable(:contract).last
raise "No contract defined" unless klass
res = klass.new.call(params)
if res.success?
res.to_h
else
raise ValidationError.new(errors: res.errors, headers: header)
end
end
end
# Install these globally, for every endpoint.
::Grape::API::Instance.extend(EndpointDSL)
::Grape::DSL::InsideRoute.include(InsideRouteHelper)
end
Now it can be used like
desc 'Create new'
contract CreateOrderContract
post do
Order.create!(contract_params)
end
or even defining the contract inline:
desc 'Create new'
contract do
params do
required(:order).filled(OrderSchema)
end
rule(:order) do
next if value[:baskets].count < 10
key.failure('contains too many baskets')
end
end
post do
See this repository for the full example.