Using Dry Validation in Rails

Ly Channa
3 min read5 days ago

--

To implement schema validation in Rails using the dry-validation gem follow these steps:

1. Install the Gem

Add the following gem to your Gemfile:

gem 'dry-validation'

2. Create a Concern for Schema Validation

Create a concern to handle schema validation: app/controllers/concerns/request_schema_validable.rb.

# frozen_string_literal: true
module RequestSchemaValidable
extend ActiveSupport::Concern

included do
# before_action :required_schema_validation!
rescue_from RequestSchemaError, with: :rescue_schema_validation_error
end

def required_schema_validation!
return true if required_schema.nil? || schema_params.blank?
result = required_schema.call(schema_params)
return true if result.success?
error_message = result.errors.to_h.to_json
raise RequestSchemaError, error_message
end

def schema_params
request.params
end

def rescue_schema_validation_error(ex)
payload = JSON.parse(ex.message)
render json: payload, status: :unprocessable_entity
end

# override this to your own schema in the controller
def required_schema
nil
end
end

3. Include the Concern in the Application Controller

Include the validation concern in your root controller: app/controllers/application_controller.rb.

# frozen_string_literal: true
class ApplicationController < ActionController::Base
include RequestSchemaValidable
end

4. Use the Validator in Specific Controllers

For example, in app/controllers/users/sessions_controller.rb, use the request validator:

# frozen_string_literal: true

module Users
class SessionsController < ApplicationController

def required_schema
OidcLogoutSchema
end
end
end

5. Create a Custom Error for Schema Validation

Define a custom error class for handling schema validation errors: app/errors/request_schema_error.rb.

# frozen_string_literal: true
class RequestSchemaError < StandardError
end

6. Create a Base Class for Schema Validation

Encapsulate validation logic in a parent class: app/request_schemas/application_request_schema.rb.

# frozen_string_literal: true

class ApplicationRequestSchema < Dry::Validation::Contract
# @return Dry::Validation::Result
def self.validate(options)
new.call(options)
end

# @return Dry::Validation::Result
def self.call(options)
new.call(options)
end
end

7. Create a Specific Schema for Validating Requests

Define the schema to validate your request, for example: app/request_schemas/oidc_logout_schema.rb.

# frozen_string_literal: true

class OidcLogoutSchema < ApplicationRequestSchema
# params schema
params do
optional(:client_id).filled(:string)
optional(:id_token_hint).value(:string)
optional(:logout_sid).value(:string)
optional(:post_logout_redirect_uri).value(:string)
end

rule(:id_token_hint) do
key.failure('is missing') if values[:id_token_hint].blank? && values[:logout_sid].blank?
end

rule(:client_id) do
key.failure('is missing') if values[:id_token_hint].blank? && values[:logout_sid].present? && values[:client_id].blank?
end

rule(:post_logout_redirect_uri) do
error_message = 'is missing'
error_url = 'must be a valid url'

value = values[:post_logout_redirect_uri]

if value.blank?
key.failure(error_message)
else
uri = URI.parse(value)
valid = uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)

key.failure(error_url) unless valid
end
rescue URI::InvalidURIError
key.failure(error_url)
end
end

8. Write RSpec Tests for the Schema

Create a spec file to test your validation logic: spec/request_schemas/oidc_logout_schema_spec.rb.

require 'rails_helper'

RSpec.describe OidcLogoutSchema do
let(:oauth_app) { create(:oauth_application) }
let(:id_token_hint) { SecureRandom.hex(32) }
let(:error_message) { 'must be filled' }
let(:error_url) { 'must be a valid url' }
let(:valid_attrs) do
{
client_id: oauth_app.uid,
post_logout_redirect_uri: oauth_app.redirect_uris.first,
id_token_hint: id_token_hint
}
end

describe 'with valid attributes' do
it 'validate successfully' do
result = described_class.validate(valid_attrs)

expect(result.success?).to be_truthy
expect(result.errors).to be_blank
end
end

describe 'with invalid attributes' do
it 'requires client_id' do
result = described_class.validate(valid_attrs.merge(client_id: ''))
errors = result.errors.to_h

expect(result.success?).to be_falsey
expect(errors.keys.count).to eq 1
expect(errors[:client_id]).to match(['must be filled'])
end

context 'logout_sid blank' do
it 'requires id_token_hint' do
result = described_class.validate(valid_attrs.merge(id_token_hint: ''))
errors = result.errors.to_h

expect(result.success?).to be_falsey
expect(errors.keys.count).to eq 1
expect(errors[:id_token_hint]).to match(['is missing'])
end
end

context 'logout_sid present' do
it 'does not requires id_token_hint' do
result = described_class.validate(valid_attrs.merge(id_token_hint: '', logout_sid: 'anything'))
errors = result.errors.to_h

expect(result.success?).to be_truthy
expect(errors).to be_blank
end
end

it 'requires post_logout_redirect_uri to be present' do
result = described_class.validate(valid_attrs.merge(post_logout_redirect_uri: ''))
errors = result.errors.to_h

expect(result.success?).to be_falsey
expect(errors.keys.count).to eq 1
expect(errors[:post_logout_redirect_uri]).to match(['is missing'])
end

it 'requires post_logout_redirect_uri to be a valid uri format' do
result = described_class.validate(valid_attrs.merge(post_logout_redirect_uri: 'ftp://jo.2883.39'))
errors = result.errors.to_h

expect(result.success?).to be_falsey
expect(errors.keys.count).to eq 1
expect(errors[:post_logout_redirect_uri]).to match([error_url])
end
end
end

This structured approach enables you to efficiently integrate dry-validation into your Rails project, allowing for modular and reusable validation schemas.

--

--

Ly Channa

Highly skilled: REST API, OAuth2, OpenIDConnect, SSO, TDD, RubyOnRails, CI/CD, Infrastruct as Code, AWS.