Tag: dry-rb

Simplifying internal validations using Dry-Validation

When building APIs for other developers, it's often important to draw the line between other programmers input data and the internal world of your library. This process is called data validation and you're probably familiar with this name.

What you may not know, is the fact that it can be achieved in many ways.

One that I particularly like is by using the dry-validation library. Here's an example on how you can separate the validation from the actual business logic without actually changing the API of your library.

The inline way

The easiest way to provide validations is to embed the checks in a place where you receive the data.

This approach is great for super simple cases like the one below:

def sum(arg1, arg2)
  raise ArgumentError unless arg1
  raise ArgumentError unless arg2

  arg1.to_i + arg2.to_i
end

sum(2, nil) #=> ArgumentError (ArgumentError)

However, if you decide to follow this road, you will quickly end up with something really unreadable.

This code sample is taken from the ruby-kafka library and it's used to validate the method input. I've removed the business logic parts as they aren't relevant to the context of this article:

def build(
  ca_cert_file_path: nil,
  ca_cert: nil,
  client_cert: nil,
  client_cert_key: nil,
  client_cert_chain: nil,
  ca_certs_from_system: nil
)
  return nil unless ca_cert_file_path ||
                    ca_cert ||
                    client_cert ||
                    client_cert_key ||
                    client_cert_chain ||
                    ca_certs_from_system

  if client_cert && client_cert_key
    # business irrelevant to the checks
    if client_cert_chain
      # business irrelevant to the checks
    end
  elsif client_cert && !client_cert_key
    raise ArgumentError, "initialized with ssl_client_cert` but no ssl_client_cert_key"
  elsif !client_cert && client_cert_key
    raise ArgumentError, "initialized with ssl_client_cert_key, but no ssl_client_cert"
  elsif client_cert_chain && !client_cert
    raise ArgumentError, "initialized with ssl_client_cert_chain, but no ssl_client_cert"
  elsif client_cert_chain && !client_cert_key
    raise ArgumentError, "initialized with ssl_client_cert_chain, but no ssl_client_cert_key"
  end

  # business
end

Despite looking simple, the if-elsif validation is really complex and it brings many things to the table:

  • it checks several variables,
  • mixes the checks together due to the if-flow,
  • in the end it actually only checks the presence of the variables,
  • despite expecting string values, it will work with anything that is provided,
  • it forces us to spec out the validation cases with the business logic as they are coupled together.

Luckily for us, there's a better way to do that.

The private schema way

We can achieve the same functionality and much more just by extracting the validations into a separate internal class. Let's build up only the interface for now.

Note, that I'm leaving the ArgumentError and the external API intact, as I don't want this change to impact anything that is outside of this class:

require 'dry-validation'

# Empty schema for now, we will get there
SCHEMA = Dry::Validation.Schema {}

def build(
  ca_cert_file_path: nil,
  ca_cert: nil,
  client_cert: nil,
  client_cert_key: nil,
  client_cert_chain: nil,
  ca_certs_from_system: nil
)
  input = {
    ca_cert_file_path: ca_cert_file_path,
    ca_cert: ca_cert,
    client_cert: client_cert,
    client_cert_key: client_cert_key,
    client_cert_chain: client_cert_chain,
    ca_certs_from_syste: ca_certs_from_system
  }

  # Do nothing if there's nothing to do
  return nil if input.values.all?(&:nil?)

  results = SCHEMA.call(input)
  raise ArgumentError, results.errors unless results.success?

  # Business logic
end

We've managed to extract the validation logic outside.

Thanks to that, now we have:

  • separation of responsibilities,
  • business applying method that we can test against only valid cases,
  • validation object that we can test in isolation,
  • much cleaner API that can be easier expanded (new arguments, new data types supported, etc) and/or replaced,
  • way to handle more complex validations (types, formats, etc),
  • support for reporting multiple issues with the input at the same time.

We can now perform all the checks and only when everything is good, we will run the business. But what about the validation itself?

Actually all the validations below are copy-pasted from the karafka repository. Here's the dry-validation documentation.

require 'dry-validation'

SCHEMA = Dry::Validation.Schema do
  %i[
    ca_cert
    ca_cert_file_path
    client_cert
    client_cert_key
    client_cert_chain
  ].each do |encryption_attribute|
    optional(encryption_attribute).maybe(:str?)
  end

  optional(:ca_certs_from_system).maybe(:bool?)

  rule(
    client_cert_with_client_cert_key: %i[
      client_cert
      client_cert_key
    ]
  ) do |client_cert, client_cert_key|
    client_cert.filled? > client_cert_key.filled?
  end

  rule(
    client_cert_key_with_client_cert: %i[
      client_cert
      client_cert_key
    ]
  ) do |client_cert, client_cert_key|
    client_cert_key.filled? > client_cert.filled?
  end

  rule(
    client_cert_chain_with_client_cert: %i[
      client_cert
      client_cert_chain
    ]
  ) do |client_cert, client_cert_chain|
    client_cert_chain.filled? > client_cert.filled?
  end

  rule(
    client_cert_chain_with_client_cert_key: %i[
      client_cert_chain
      client_cert_key
    ]
  ) do |client_cert_chain, client_cert_key|
    client_cert_chain.filled? > client_cert_key.filled?
  end
end

The execution effect is also really good:

build(ca_cert: 2) #=> {:ca_cert=>["must be a string"]} (ArgumentError)

Summary

Whenever you find yourself adding some inline validations, stop and think twice, there's probably a better and more extendable way to do it.


Originally published at The Castle blog.

Dry-Validation as a schema validation layer for Ruby on Rails API

Legacy code is never the way we would like it to be

There are days, when you don't get to write your new shiny application using Grape and JSON-API. Life would be much easier, if we could always start from the beginning. Unfortunately we can't and one of the things that make some developers more valuable that others is their ability to adapt. The app might be outdated, it might have abandoned gems, but if you're able to introduce new concepts to it (without breaking it and having to rewrite everything), you should be able to overcome any other difficulties.

ActiveRecord::Validations is not the way to go

If you use Rails, then probably you use ActiveRecord::Validations as the main validation layer. Model validations weren't designed as a protection layer for ensuring incoming data schema consistency (and there are many more reasons why you should stop using them).

External world and its data won't always resemble your internal application structure. Instead of trying to adapt the code-base and fit it into the world, try making the world as much constant and predictable as it can be.

One of the best things in that matter is to ensure that any endpoint input data is as strict as it can be. You can do quite a lot, before it gets to your ActiveRecord models or hits any business logic.

Dry-Validation - different approach to solve different problems

Unlike other, well known, validation solutions in Ruby, dry-validation takes a different approach and focuses a lot on explicitness, clarity and precision of validation logic. It is designed to work with any data input, whether it’s a simple hash, an array or a complex object with deeply nested data.

Dry-validation is a library that can help you protect your typical Rails API endpoints better. You will never control what you will receive, but you can always make sure that it won't reach your business.

Here's a basic example of how it works (assuming we validate a single hash):

BeaconSchema = Dry::Validation.Schema do
  UUID_REGEXP = /\A([a-z|0-9]){32}\z/
  PROXIMITY_ZONES = %w(
    immediate
    near
    far
    unknown
  )

  required(:uuid).filled(:str?, size?: 32, format?: UUID_REGEXP)
  required(:rssi).filled(:int?, lteq?: -26, gteq?: -100)
  required(:major).filled(:int?, gteq?: 0, lteq?: 65535)
  required(:minor).filled(:int?, gteq?: 0, lteq?: 65535)
  required(:proximity_zone).filled(:str?, included_in?: PROXIMITY_ZONES)
  required(:distance) { filled? & ( int? | float? ) & gteq?(0) & lteq?(100)  }
end

data = {}
validation_result = BeaconSchema.call(data)
validation_result.errors #=> {:uuid=>["is missing"], :rssi=>["is missing"], ... }

The validation result object is really similar to the validation result of ActiveRecord::Validation in terms of usability.

Dry Ruby on Rails API

Integrating dry-validation with your Ruby on Rails API endpoints can give you multiple benefits, including:

  • No more strong parameters (they will become useless when you cover all the endpoints with dry-validation)
  • Schemas testing in isolation (not in the controller request - response flow)
  • Model validations that focus only on the core details (without the "this data is from form and this from the api" dumb cases)
  • Less coupling in your business logic
  • Safer extending and replacing of endpoints
  • More DRY
  • Nested structures validation

Non-obstructive data schema for a controller layer

There are several approaches you can take when implementing schema validation for your API endpoints. One that I find useful especially in legacy projects is the #before_action approach. Regardless whether #before_action is (or is not) an anti-pattern, in this case, it can be used to provide non-obstructive schema and data validation that does not interact with other layers (like services, models, etc.).

To implement such protection, you only need a couple lines of code):

class Api::BaseController < ApplicationController
  # Make sure that request data meets schema requirements
  before_action :ensure_schema!

  # Accessor for validation results hash
  attr_reader :validation_result
  # Schema assigned on a class level
  class_attribute :schema

  private

  def ensure_schema!
    @validation_result = self.class.schema.call(params)
    return if @validation_result.success?

    render json: @validation_result.errors.to_json, status: :unprocessable_entity
  end
end

and in your final API endpoint that inherits from the Api::BaseController:

class Api::BeaconsController < Api::BaseController
  self.schema = BeaconSchema

  def create
    # Of course you can still have validations on a beacon level
    Beacon.create!(validation_result.to_h)
    head :no_content
  end
end

Summary

Dry-validation is one of my "must use" gems when working with any type of incoming data (even when it comes from my other systems). As presented above, it can be useful in many situations and can help reduce the number of responsibilities imposed on models.

This type of validation layer can also serve as a great starting point for any bigger refactoring process. Ensuring incoming data and schema consistency without having to refer to any business models allows to block the API without blocking the business and models behind it.

Cover photo by: brlnpics123 on Creative Commons 2.0 license. Changes made: added an overlay layer with an article title on top of the original picture.

Copyright © 2024 Closer to Code

Theme by Anders NorenUp ↑