Валидация JSON в Rails
Иногда нам приходится добавлять JSON аттрибут в нашу базу данных для удобной поддержки слабо-структурированных данных. Например:
- социальные сети пользователя. Он может указать Вконтакте, Facebook, Twitter и так далее. Все это можно хранить в 1 JSON аттрибуте;
- настройки нотификации пользователя. Какие письма он хочет получать, какие нет;
- конфиги/сериализованные представления объектов - представление объекта
IceCube
для рекурентных событий, конфиги на основании которых приложение должно выглядеть/работать по другому и т.д.;
контекст
Допустим имеется некий аналог трелло с дополнительными фичами:
- Пользователь может создать доску для своих карточек - введя название доски
- Пользователь может создать карточку внутри своей доски - надо ввести название, описание и указать приоритет карточки
- Пользователь может увидеть созданную карточку и свое имя в поле “Автор” у созданной карточки
- Администратор может слегка изменить форму создания карточки + создать шаблон по умолчанию для всех новых карточек:
- Aдмин может изменить плейсхолдеры в форме для названия, описания карточки
- Админ может задать шаблон для новых карточек - предустановленные значения названия, описания и приоритета
- Админ может задать пользователя для шаблонной карточки - его имя будет отображаться в графе “Автор”
Все эти настройки админа решено хранить в поле JSON(B) таблицы card_templates
. Структура конфига:
{
form_title: "Create your card",
title_hint: "Enter card title",
title_placeholder: "My new card",
description_hint: "Enter card description",
description_placeholder: "Need to increase my profit",
default_card: {
title: "My new card",
description: "Need to increase my profit",
priority: {
value: "M",
effort: "M",
number_value: 1
},
author: {
name: "John Doe"
}
}
}
проблема
Как было упомянуто выше, мы используем JSON из-за его гибкой структуры, но это не значит что пользователь может ввести все что захочет. Мы (разработчики приложения) сами задаем удобную для нас структуру JSON аттрибута и пишем логику исходя из этой структуры. Любое отклонение от зафиксированной схемы может вызвать ошибку в приложении. Например, мы сделали API для того чтобы администратор мог создавать свои шаблоны, но практически сразу начали замечать что все созданные конфиги отличаются по структуре от той, что мы ожидаем - нам нужна валидация. Увы, ActiveRecord
на данный момент не позволяет нам удобно описывать валидации для наших JSON данных, поэтому нам нужно придумать что-то еще.
data transfer object
Нам нужен некоторый объект, который получает любой хеш, возвращая при этом данные в строго заданной структуре. Для таких целей может подойти DTO (Data transfer object), задача которого как раз в этом и состоит - он нужен для передачи данных между разными частями приложения, при этом эти данные он может структурировать так, как это требуется для работы приложения.
решение
Мы решили что воспользуемся паттерном DTO и теперь нужно его реализовать. Или не нужно? :)
dry-struct
(link) - один из гемов dry-rb
реализует как раз то, что нам нужно, предлагая удобный способ для описания наших структур. Файловая иерархия DTO в нашем проекте:
Каждый файл по отдельности:
types.rb
module Types
include Dry.Types()
end
Нэймспейс где будут храниться типы из dry-struct
для описания наших структур
base_dto.rb
class BaseDto < Dry::Struct
transform_keys(&:to_sym)
DEFAULT_STRING_VALUE = ''.freeze
end
Базовый класс для всех наших DTO. Здесь же есть инструкция что ключи входного хеша должны быть преобразованы в символы
templates/card_settings_dto.rb
module Templates
class CardSettingsDto < BaseDto
DEFAULT_CARD_VALUE = Templates::CardSettings::ExampleDto.new.to_h.freeze
attribute :form_title, Types::Strict::String.default(DEFAULT_STRING_VALUE)
attribute :title_hint, Types::Strict::String.default(DEFAULT_STRING_VALUE)
attribute :title_placeholder, Types::Strict::String.default(DEFAULT_STRING_VALUE)
attribute :description_hint, Types::Strict::String.default(DEFAULT_STRING_VALUE)
attribute :description_placeholder, Types::Strict::String.default(DEFAULT_STRING_VALUE)
attribute :default_card, Templates::CardSettings::ExampleDto.default(DEFAULT_CARD_VALUE)
end
end
Описали структуру конфига, который может создать администратор. Аттрибут default_card
- сложная структура, описываемая в другом DTO классе
templates/card_settings/example_dto.rb
module Templates
module CardSettings
class ExampleDto < BaseDto
DEFAULT_AUTHOR = Templates::CardSettings::AuthorDto.new.to_h.freeze
DEFAULT_PRIORITY = Templates::CardSettings::PriorityDto.new.to_h.freeze
attribute :title, Types::Strict::String.default(DEFAULT_STRING_VALUE)
attribute :description, Types::Strict::String.default(DEFAULT_STRING_VALUE)
attribute :priority, Templates::CardSettings::PriorityDto.default(DEFAULT_PRIORITY)
attribute :author, Templates::CardSettings::AuthorDto.default(DEFAULT_AUTHOR)
end
end
end
Описали структуру дефолтной карточки. Аттрибуты priority
и author
- вложенные объекты, описанные в других DTO классах
templates/card_settings/priority_dto.rb
module Templates
module CardSettings
class PriorityDto < BaseDto
DEFAULT_PRIORITY_VALUE = 'm'.freeze
PRIORITY_VALUES = %w[s m l xl].freeze
DEFAULT_PRIORITY_NUMBER_VALUE = 1
PRIORITY_NUMBER_VALUES = (0..3).to_a.freeze
attribute :value, Types::Strict::String
.default(DEFAULT_PRIORITY_VALUE)
.constrained(included_in: PRIORITY_VALUES)
attribute :effort, Types::Strict::String
.default(DEFAULT_PRIORITY_VALUE)
.constrained(included_in: PRIORITY_VALUES)
attribute :number_value, Types::Coercible::Integer
.default(DEFAULT_PRIORITY_NUMBER_VALUE)
.constrained(included_in: PRIORITY_NUMBER_VALUES)
end
end
end
Описали шейп объекта приоритетов + добавили констрейнты для допустимых значении.
templates/card_settings/author_dto.rb
module Templates
module CardSettings
class AuthorDto < BaseDto
attribute :name, Types::Strict::String.default(DEFAULT_STRING_VALUE)
end
end
end
Шейп объекта author
состоит просто из его имени.
примеры использования
Пустой хеш:
[1] pry(main)> empty_hash = {}
=> {}
[2] pry(main)> card_settings = ::Templates::CardSettingsDto.new(empty_hash)
=> #<Templates::CardSettingsDto form_title="" title_hint="" title_placeholder="" description_hint="" description_placeholder="" default_card={:title=>"", :description=>"", :priority=>{:value=>"m", :effort=>"m", :number_value=>1}, :author=>{:name=>""}}>
[3] pry(main)> card_settings.to_h
=> {:form_title=>"",
:title_hint=>"",
:title_placeholder=>"",
:description_hint=>"",
:description_placeholder=>"",
:default_card=>{:title=>"", :description=>"", :priority=>{:value=>"m", :effort=>"m", :number_value=>1}, :author=>{:name=>""}}}
Как видно из примера выше, наш DTO проставляет значения согласно тем значениям по умолчанию, что мы задали.
Хеш, содержащий неправильные значения (нарушающее constrained
правила):
[4] pry(main)> invalid_hash = { default_card: { priority: { value: 'wrong' } } }
=> {:default_card=>{:priority=>{:value=>"wrong"}}}
[5] pry(main)> card_settings = ::Templates::CardSettingsDto.new(invalid_hash)
Dry::Struct::Error: [Templates::CardSettings::PriorityDto.new] "wrong" (String) has invalid type for :value violates constraints (included_in?(["s", "m", "l", "xl"], "wrong") failed)
from /home/ruslan/.rbenv/versions/3.0.2/lib/ruby/gems/3.0.0/gems/dry-types-1.5.1/lib/dry/types/schema.rb:330:in `rescue in block in resolve_unsafe'
Caused by Dry::Types::SchemaError: "wrong" (String) has invalid type for :value violates constraints (included_in?(["s", "m", "l", "xl"], "wrong") failed)
from /home/ruslan/.rbenv/versions/3.0.2/lib/ruby/gems/3.0.0/gems/dry-types-1.5.1/lib/dry/types/schema.rb:330:in `rescue in block in resolve_unsafe'
Caused by Dry::Types::ConstraintError: "wrong" violates constraints (included_in?(["s", "m", "l", "xl"], "wrong") failed)
from /home/ruslan/.rbenv/versions/3.0.2/lib/ruby/gems/3.0.0/gems/dry-types-1.5.1/lib/dry/types/constrained.rb:42:in `call_unsafe'
При передаче значении, непредусмотренных constrained
директивой мы получаем исключение.
Хеш, содержащий правильные значения, но имеющий лишние аттрибуты + некоторые аттрибуты структуры отсутствуют:
some_hash = { unused_attribute: 'UID12345', form_title: 'Create your card', title_hint: 'Enter card title', description_hint: 'Enter card description', default_card: { title: 'My new card', author: { name: 'John Doe' }, priority: { value: 'm', effort: 's', number_value: 2 } } }
=> {:unused_attribute=>"UID12345",
:form_title=>"Create your card",
:title_hint=>"Enter card title",
:description_hint=>"Enter card description",
:default_card=>{:title=>"My new card", :author=>{:name=>"John Doe"}, :priority=>{:value=>"m", :effort=>"s", :number_value=>2}}}
[9] pry(main)> card_settings = ::Templates::CardSettingsDto.new(some_hash)
=> #<Templates::CardSettingsDto form_title="Create your card" title_hint="Enter card title" title_placeholder="" description_hint="Enter card description" description_placeholder="" default_card=#<Templates::CardSettings::ExampleDto title="My new card" description="" priority=#<Templates::CardSettings::PriorityDto value="m" effort="s" number_value=2> author=#<Templates::CardSettings::AuthorDto name="John Doe">>>
[10] pry(main)> card_settings.to_h
=> {:form_title=>"Create your card",
:title_hint=>"Enter card title",
:title_placeholder=>"",
:description_hint=>"Enter card description",
:description_placeholder=>"",
:default_card=>{:title=>"My new card", :description=>"", :priority=>{:value=>"m", :effort=>"s", :number_value=>2}, :author=>{:name=>"John Doe"}}}
Наш DTO вырезал ненужные аттрибуты + добавил пропущенные аттрибуты, согласно их значению по умолчанию.
заключение
C помощью паттерна DTO и ее реализации в виде dry-types
можно достаточно просто добавить валидацию к нашим JSON данным. Также это позволит явно описать шейп нашего JSON - объекта и обращаться к нему, чтобы вспомнить что-же хранится в нашем объекте и какие у него могут быть значения :)