Rails Form Objects With dry-rb
Building form objects in modern Rails applications is nothing new. Most Ruby developers are familiar with including Virtus and ActiveModel::Validations to their form objects. Today I would like to show how to build a form object using dry-types and dry-validation
Let's build a simple form object for creating postcards. We have 3 models inside the application:
Country
withname
andis_state_required
fields. The second field indicates that to create a proper address, a user needs to provide a state - like in the US.Country::State
withname
andcountry_id
.Postcard
withstate_id
,country_id
,content
and address fields.
Definition of done
- Form creates new postcard (quite obvious).
- Validates presence of address, city, zip code, content and country.
- Validates format of zip code.
- Validates length of content (if you want something short just tweet it or text it).
- If selected country requires a state, presence of state should be validated.
Attributes and types
Let's start with attributes definition. Our form object needs to inherit from Dry::Types::Struct
.
Dry will generate appropriate getters and the constructor.
class Postcard
module Types
include Dry::Types.module
end
class CreateForm < Dry::Types::Struct
attribute :address, Types::Coercible::String
attribute :city, Types::Coercible::String
attribute :zip_code, Types::Coercible::String
attribute :content, Types::Coercible::String
end
end
Just include Dry::Types.module
module and use the types.
Dry-types comes with a wide choice of primitive types along with modifiers.
Things get a bit more complicated when we would like to use our rails models.
We need to register our types in order to create attributes with these types.
Which is done as follows:
TypeName = Dry::Types::Definition.new(::MyRubyClass)
You can also tell dry-types how should the Type be constructed by calling .constructor
with a block.
So our definitions looks like this:
module Types
include Dry::Types.module
Country = Dry::Types::Definition.new(::Country)
CountryState = Dry::Types::Definition.new(::Country::State)
end
Now we can use Country
and CountryState
as types. So finally form's definition looks like this:
class CreateForm < Dry::Types::Struct
attribute :address, Types::Coercible::String
attribute :city, Types::Coercible::String
attribute :zip_code, Types::Coercible::String
attribute :content, Types::Coercible::String
attribute :country, Types::Country
attribute :state, Types::CountryState
end
Yeah, we managed to create a simple struct.
A note about the dry-types struct constructor
If we don't specify constructor type, a strict constructor will be generated, which means it will throw ArgumentError
if an attribute is missing.
We will handle presence validation using dry-validation so we will use schema or symbolized constructor
more information on constructor types here.
To use schema constructor type we need to call constructor_type(:schema)
inside class body.
Validations
To perform validations inside our form object we'll use dry-validation gem. It comes with a wide variety of predicates, which are simple to use. Lets start with presence validation:
PostcardSchema = Dry::Validation.Schema do
required(:address).filled
required(:city).filled
required(:zip_code).filled
required(:content).filled
required(:country).filled
end
We create a schema to which we pass model attributes, defined previously, like this:
errors = PostcardSchema.call(to_hash).messages(full: true)
Ok so what's happened here:
to_hash
(orto_h
) generates a hash based on attributes.messages(full: true)
returns full error messages
To pass more requirements to validation, like format, length and so on just pass parameters to .filled
method.
Lets take content as an example, not only it should be present but also it should be longer than 20 characters:
required(:content).filled(min_size?: 20)
The full list of ready to use predicates can be accessed here.
More complex validation logic
Presence or length validations are delivered by dry-validation. Unfortunately(?) in real live application those are usually not enough, that's why dry-validation allows us to write our own predicates.
Let start with a simple one, which will check if the country passed to validation requires state.
PostcardSchema = Dry::Validation.Schema do
configure do
config.messages_file = Rails.root.join('config/locales/errors.yml')
def state_required?(country)
country.is_state_required
end
end
# (...)
end
As simple as that, just remember to put proper error message to your errors.yml
file, more information about errors file here.
So let's get to the core and check presence of state only if the country requires it. We need to tell validation that the state might be present (or might be not). To do this just put the following line to our schema:
required(:state).maybe
Defining the rule itself
Defining the rule itself goes as follows:
rule(country_requires_state: [:country, :state]) do |country, state|
country.state_required? > state.filled?
end
As simple as that:
- we pass rule name along with the fields that are required inside the rule. In our case we use country and state.
- Those variables are yielded to the block.
- The rule is translated like this if state is required then check presence of state.
More information about high level rules is available here
Finished form object
class Postcard
module Types
include Dry::Types.module
Country = Dry::Types::Definition
.new(::Country)
CountryState = Dry::Types::Definition
.new(::Country::State)
end
class CreateForm < Dry::Types::Struct
constructor_type(:schema)
ZIP_CODE_FORMAT = /\d{5}/
MINIMAL_CONTENT_LENGTH = 20
attribute :address, Types::Coercible::String
attribute :city, Types::Coercible::String
attribute :zip_code, Types::Coercible::String
attribute :content, Types::Coercible::String
attribute :country, Types::Country
attribute :state, Types::CountryState
def save!
errors = PostcardSchema.call(to_hash).messages(full: true)
raise CommandValidationFailed, errors if errors.present?
Postcard.create!(to_hash)
end
private
PostcardSchema = Dry::Validation.Schema do
configure do
config.messages_file = Rails.root.join('config/locales/errors.yml')
def state_required?(country)
country.is_state_required
end
end
required(:address).filled
required(:city).filled
required(:zip_code).filled(format?: ZIP_CODE_FORMAT)
required(:content).filled(min_size?: MINIMAL_CONTENT_LENGTH)
required(:state).maybe
required(:country).filled
rule(country_requires_state: [:country, :state]) do |country, state|
country.state_required? > state.filled?
end
end
end
end
Full project, including models and spec is available on my github.
Possible refactor
Just to make things simple and readable in a blog post I put everything connected to the object itself into one file. In real life application this is probably not the best way of organizing your codebase. What can be done:
Types
module could be placed as a separate module, probably even a global scope onePostcardSchema
could be placed outside this form object and used in, for example, update form- the same goes to constants -
ZIP_CODE_FORMAT
andMINIMAL_CONTENT_LENGTH
Conclusion
Using dry-types allows you to write type safe components to your application. This library comes with a wide choice of ready to use types and it's quite easy to define your own.
I feel like dry-validation approach is more clean than ActiveModel one. You put all your validation logic inside one, clearly bounded place. Those validations can be easily reused by other forms (like update ones).
The biggest problem with dry-rb, similarly to ROM and Roda, is a lack of documentation which allows fresh users to start easily. Trust me or not I spent over 2 hours writing the form object. Mostly because the problems with the documentation and lack of blogposts. So hopefuly this blogpost will save someone's 2 hours.