├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── Gemfile ├── LICENSE.md ├── README.md ├── Rakefile ├── activerecord_json_validator.gemspec ├── docker-compose.yml ├── lib ├── active_record │ └── json_validator │ │ ├── validator.rb │ │ └── version.rb └── activerecord_json_validator.rb └── spec ├── json_validator_spec.rb ├── spec_helper.rb └── support └── macros ├── database ├── database_adapter.rb ├── mysql_adapter.rb └── postgresql_adapter.rb ├── database_macros.rb └── model_macros.rb /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - '**' 10 | 11 | jobs: 12 | ci: 13 | runs-on: ubuntu-latest 14 | 15 | services: 16 | db: 17 | image: postgres:10.19 18 | env: 19 | POSTGRES_DB: activerecord_json_validator_test 20 | POSTGRES_USER: postgres 21 | POSTGRES_PASSWORD: postgres 22 | ports: ['5432:5432'] 23 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 24 | 25 | env: 26 | CANONICAL_HOST: localhost 27 | DATABASE_URL: postgres://postgres:postgres@localhost/activerecord_json_validator_test 28 | DB_ADAPTER: postgresql 29 | 30 | steps: 31 | - uses: actions/checkout@v2 32 | - uses: ruby/setup-ruby@v1 33 | with: 34 | ruby-version: '2.7.5' 35 | bundler-cache: true 36 | - run: bundle exec rubocop 37 | - run: bundle exec rake spec 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Include: 3 | - Rakefile 4 | - Gemfile 5 | - '**/*.rb' 6 | Exclude: 7 | - activerecord_json_validator.gemspec 8 | - vendor/**/* 9 | 10 | Documentation: 11 | Enabled: false 12 | 13 | Encoding: 14 | Enabled: false 15 | 16 | LineLength: 17 | Max: 200 18 | 19 | AccessModifierIndentation: 20 | EnforcedStyle: outdent 21 | 22 | IfUnlessModifier: 23 | Enabled: false 24 | 25 | CaseIndentation: 26 | EnforcedStyle: case 27 | IndentOneStep: true 28 | 29 | MethodLength: 30 | CountComments: false 31 | Max: 20 32 | 33 | SignalException: 34 | Enabled: false 35 | 36 | ColonMethodCall: 37 | Enabled: false 38 | 39 | AsciiComments: 40 | Enabled: false 41 | 42 | RegexpLiteral: 43 | Enabled: false 44 | 45 | AssignmentInCondition: 46 | Enabled: false 47 | 48 | ParameterLists: 49 | CountKeywordArgs: false 50 | 51 | SingleLineBlockParams: 52 | Methods: 53 | - reduce: 54 | - memo 55 | - item 56 | 57 | Metrics/AbcSize: 58 | Enabled: false 59 | 60 | Style/CollectionMethods: 61 | Enabled: true 62 | 63 | Style/SymbolArray: 64 | Enabled: true 65 | 66 | Style/ExtraSpacing: 67 | Enabled: true 68 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2025, Mirego 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | - Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | - Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | - Neither the name of the Mirego nor the names of its contributors may 13 | be used to endorse or promote products derived from this software without 14 | specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 19 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 20 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 21 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 22 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 23 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 24 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 25 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |
6 | ActiveRecord::JSONValidator makes it easy to validate
JSON attributes against a JSON schema. 7 |

8 | 9 | 10 |

11 | 12 | --- 13 | 14 | ## Installation 15 | 16 | Add this line to your application's Gemfile: 17 | 18 | ```ruby 19 | gem 'activerecord_json_validator', '~> 3.1.0' 20 | ``` 21 | 22 | ## Usage 23 | 24 | ### JSON Schema 25 | 26 | Schemas should be a JSON file 27 | 28 | ```json 29 | { 30 | "type": "object", 31 | "$schema": "http://json-schema.org/draft-04/schema#", 32 | "properties": { 33 | "city": { "type": "string" }, 34 | "country": { "type": "string" } 35 | }, 36 | "required": ["country"] 37 | } 38 | ``` 39 | 40 | ### Ruby 41 | 42 | ```ruby 43 | create_table "users" do |t| 44 | t.string "name" 45 | t.json "profile" # First-class JSON with PostgreSQL, yo. 46 | end 47 | 48 | class User < ActiveRecord::Base 49 | # Constants 50 | PROFILE_JSON_SCHEMA = Rails.root.join('config', 'schemas', 'profile.json') 51 | 52 | # Validations 53 | validates :name, presence: true 54 | validates :profile, presence: true, json: { schema: PROFILE_JSON_SCHEMA } 55 | end 56 | 57 | user = User.new(name: 'Samuel Garneau', profile: { city: 'Quebec City' }) 58 | user.valid? # => false 59 | 60 | user = User.new(name: 'Samuel Garneau', profile: { city: 'Quebec City', country: 'Canada' }) 61 | user.valid? # => true 62 | 63 | user = User.new(name: 'Samuel Garneau', profile: '{invalid JSON":}') 64 | user.valid? # => false 65 | user.profile_invalid_json # => '{invalid JSON":}' 66 | ``` 67 | 68 | #### Options 69 | 70 | | Option | Description | 71 | | ---------- | ------------------------------------------------------------------------------------------------------------------------------ | 72 | | `:schema` | The JSON schema to validate the data against (see **Schema** section) | 73 | | `:value` | The actual value to use when validating (see **Value** section) | 74 | | `:message` | The ActiveRecord message added to the record errors (see **Message** section) | 75 | | `:options` | A `Hash` of [`json_schemer`](https://github.com/davishmcclurg/json_schemer#options)-supported options to pass to the validator | 76 | 77 | ##### Schema 78 | 79 | `ActiveRecord::JSONValidator` uses the [json_schemer](https://github.com/davishmcclurg/json_schemer) gem to validate the JSON 80 | data against a JSON schema. 81 | 82 | Additionally, you can use a `Symbol` or a `Proc`. Both will be executed in the 83 | context of the validated record (`Symbol` will be sent as a method and the 84 | `Proc` will be `instance_exec`ed) 85 | 86 | ```ruby 87 | class User < ActiveRecord::Base 88 | # Constants 89 | PROFILE_REGULAR_JSON_SCHEMA = Rails.root.join('config', 'schemas', 'profile.json_schema') 90 | PROFILE_ADMIN_JSON_SCHEMA = Rails.root.join('config', 'schemas', 'profile_admin.json_schema') 91 | 92 | # Validations 93 | validates :profile, presence: true, json: { schema: lambda { dynamic_profile_schema } } # `schema: :dynamic_profile_schema` would also work 94 | 95 | def dynamic_profile_schema 96 | admin? ? PROFILE_ADMIN_JSON_SCHEMA : PROFILE_REGULAR_JSON_SCHEMA 97 | end 98 | end 99 | ``` 100 | 101 | The schema is passed to the `JSONSchemer.schema` function, so it can be anything supported by it: 102 | 103 | ```ruby 104 | class User < ActiveRecord::Base 105 | # Constants 106 | JSON_SCHEMA = Rails.root.join('config', 'schemas', 'profile.json_schema') 107 | # JSON_SCHEMA = { 'type' => 'object', 'properties' => { 'foo' => { 'type' => 'integer', 'minimum' => 3 } } } 108 | # JSON_SCHEMA = '{"type":"object","properties":{"foo":{"type":"integer","minimum":3}}}' 109 | 110 | # Validations 111 | validates :profile, presence: true, json: { schema: JSON_SCHEMA } 112 | end 113 | ``` 114 | 115 | ##### Value 116 | 117 | By default, the validator will use the “getter” method to the fetch attribute 118 | value and validate the schema against it. 119 | 120 | ```ruby 121 | # Will validate `self.foo` 122 | validates :foo, json: { schema: SCHEMA } 123 | ``` 124 | 125 | But you can change this behavior if the getter method doesn’t return raw JSON data (a `Hash`): 126 | 127 | ```ruby 128 | # Will validate `self[:foo]` 129 | validates :foo, json: { schema: SCHEMA, value: ->(record, _, _) { record[:foo] } } 130 | ``` 131 | 132 | You could also implement a “raw getter” if you want to avoid the `value` option: 133 | 134 | ```ruby 135 | # Will validate `self[:foo]` 136 | validates :raw_foo, json: { schema: SCHEMA } 137 | 138 | def raw_foo 139 | self[:foo] 140 | end 141 | ``` 142 | 143 | ##### Message 144 | 145 | Like any other ActiveModel validation, you can specify either a `Symbol` or 146 | `String` value for the `:message` option. The default value is `:invalid_json`. 147 | 148 | However, you can also specify a `Proc` that returns an array of errors. The 149 | `Proc` will be called with a single argument — an array of errors returned by 150 | the JSON schema validator. So, if you’d like to add each of these errors as 151 | a first-level error for the record, you can do this: 152 | 153 | ```ruby 154 | class User < ActiveRecord::Base 155 | # Validations 156 | validates :profile, presence: true, json: { message: ->(errors) { errors }, schema: 'foo.json_schema' } 157 | end 158 | 159 | user = User.new.tap(&:valid?) 160 | user.errors.full_messages 161 | # => [ 162 | # 'The property '#/email' of type Fixnum did not match the following type: string in schema 2d44293f-cd9d-5dca-8a6a-fb9db1de722b#', 163 | # 'The property '#/full_name' of type Fixnum did not match the following type: string in schema 2d44293f-cd9d-5dca-8a6a-fb9db1de722b#', 164 | # ] 165 | ``` 166 | 167 | ## Development 168 | 169 | The tests require a database. We've provided a simple `docker-compose.yml` that will make 170 | it trivial to run the tests against PostgreSQL. Simply run `docker compose up -d` 171 | followed by `rake spec`. When you're done, run `docker compose down` to stop the database. 172 | 173 | In order to use another database, simply define the `DATABASE_URL` environment variable 174 | appropriately. 175 | 176 | ## License 177 | 178 | `ActiveRecord::JSONValidator` is © 2013-2025 [Mirego](https://www.mirego.com) and may be freely distributed under the [New BSD license](https://opensource.org/licenses/BSD-3-Clause). See the [`LICENSE.md`](https://github.com/mirego/activerecord_json_validator/blob/master/LICENSE.md) file. 179 | 180 | The tree logo is based on [this lovely icon](https://thenounproject.com/term/tree/51004/) by [Sara Quintana](https://thenounproject.com/sara.quintana.75), from The Noun Project. Used under a [Creative Commons BY 3.0](https://creativecommons.org/licenses/by/3.0/) license. 181 | 182 | ## About Mirego 183 | 184 | [Mirego](https://www.mirego.com) is a team of passionate people who believe that work is a place where you can innovate and have fun. We're a team of [talented people](https://life.mirego.com) who imagine and build beautiful Web and mobile applications. We come together to share ideas and [change the world](https://www.mirego.org). 185 | 186 | We also [love open-source software](https://open.mirego.com) and we try to give back to the community as much as we can. 187 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler' 4 | require 'rake' 5 | require 'bundler/gem_tasks' 6 | require 'rspec/core/rake_task' 7 | 8 | task default: :spec 9 | 10 | desc 'Run all specs' 11 | RSpec::Core::RakeTask.new(:spec) do |task| 12 | task.pattern = 'spec/**/*_spec.rb' 13 | end 14 | 15 | desc 'Start an IRB session with the gem' 16 | task :console do 17 | $LOAD_PATH.unshift File.expand_path(__dir__) 18 | require 'activerecord_json_validator' 19 | require 'irb' 20 | 21 | ARGV.clear 22 | IRB.start 23 | end 24 | -------------------------------------------------------------------------------- /activerecord_json_validator.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'active_record/json_validator/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'activerecord_json_validator' 8 | spec.version = ActiveRecord::JSONValidator::VERSION 9 | spec.authors = ['Rémi Prévost'] 10 | spec.email = ['rprevost@mirego.com'] 11 | spec.description = 'ActiveRecord::JSONValidator makes it easy to validate JSON attributes with a JSON schema.' 12 | spec.summary = spec.description 13 | spec.homepage = 'https://github.com/mirego/activerecord_json_validator' 14 | spec.license = 'BSD 3-Clause' 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ['lib'] 20 | 21 | spec.add_development_dependency 'bundler', '>= 1.12' 22 | spec.add_development_dependency 'rake' 23 | spec.add_development_dependency 'rspec', '~> 3.5' 24 | spec.add_development_dependency 'pg' 25 | spec.add_development_dependency 'activesupport', '>= 4.2.0', '< 9' 26 | spec.add_development_dependency 'rubocop', '~> 0.28' 27 | spec.add_development_dependency 'rubocop-rspec', '~> 1.44' 28 | spec.add_development_dependency 'rubocop-standard', '~> 6.0' 29 | 30 | spec.add_dependency 'json_schemer', '~> 2.2' 31 | spec.add_dependency 'activerecord', '>= 4.2.0', '< 9' 32 | end 33 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.2' 2 | 3 | services: 4 | postgres: 5 | image: postgres:10 6 | ports: 7 | - 5432:5432 8 | restart: on-failure 9 | environment: 10 | POSTGRES_DB: activerecord_json_validator_test 11 | POSTGRES_HOST_AUTH_METHOD: trust # don't require password 12 | -------------------------------------------------------------------------------- /lib/active_record/json_validator/validator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class JsonValidator < ActiveModel::EachValidator 4 | def initialize(options) 5 | options.reverse_merge!(message: :invalid_json) 6 | options.reverse_merge!(schema: nil) 7 | options.reverse_merge!(options: {}) 8 | options.reverse_merge!(value: ->(_record, _attribute, value) { value }) 9 | @attributes = options[:attributes] 10 | 11 | super 12 | 13 | inject_setter_method(options[:class], @attributes) 14 | end 15 | 16 | # Validate the JSON value with a JSON schema path or String 17 | def validate_each(record, attribute, value) 18 | # Get the _actual_ attribute value, not the getter method value 19 | value = options.fetch(:value).call(record, attribute, value) 20 | 21 | # Validate value with JSON Schemer 22 | errors = JSONSchemer.schema(schema(record), **options.fetch(:options)).validate(value).to_a 23 | 24 | # Everything is good if we don’t have any errors and we got valid JSON value 25 | return if errors.empty? && record.send(:"#{attribute}_invalid_json").blank? 26 | 27 | # Add error message to the attribute 28 | details = errors.map { |e| e.fetch('error') } 29 | message(errors).each do |error| 30 | error = error.fetch('error') if error.is_a?(Hash) 31 | record.errors.add(attribute, error, errors: details, value: value) 32 | end 33 | end 34 | 35 | protected 36 | 37 | # Redefine the setter method for the attributes, since we want to 38 | # catch JSON parsing errors. 39 | def inject_setter_method(klass, attributes) 40 | return if klass.nil? 41 | 42 | attributes.each do |attribute| 43 | klass.prepend(Module.new do 44 | attr_reader :"#{attribute}_invalid_json" 45 | 46 | define_method "#{attribute}=" do |args| 47 | begin 48 | instance_variable_set("@#{attribute}_invalid_json", nil) 49 | args = ::ActiveSupport::JSON.decode(args) if args.is_a?(::String) 50 | super(args) 51 | rescue ActiveSupport::JSON.parse_error 52 | instance_variable_set("@#{attribute}_invalid_json", args) 53 | super({}) 54 | end 55 | end 56 | end) 57 | end 58 | end 59 | 60 | # Return a valid schema, recursively calling 61 | # itself until it gets a non-Proc/non-Symbol value. 62 | def schema(record, schema = nil) 63 | schema ||= options.fetch(:schema) 64 | 65 | case schema 66 | when Proc then schema(record, record.instance_exec(&schema)) 67 | when Symbol then schema(record, record.send(schema)) 68 | else schema 69 | end 70 | end 71 | 72 | def message(errors) 73 | message = options.fetch(:message) 74 | message = message.call(errors) if message.is_a?(Proc) 75 | [message].flatten 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/active_record/json_validator/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord 4 | module JSONValidator 5 | VERSION = '3.1.0' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/activerecord_json_validator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_record' 4 | require 'json_schemer' 5 | 6 | require 'active_record/json_validator/version' 7 | require 'active_record/json_validator/validator' 8 | 9 | # NOTE: In case `"JSON"` is treated as an acronym by `ActiveSupport::Inflector`, 10 | # make `JSONValidator` available too. 11 | JSONValidator = JsonValidator 12 | -------------------------------------------------------------------------------- /spec/json_validator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # rubocop:disable Metrics/BlockLength 4 | require 'spec_helper' 5 | 6 | module CountryDefaulter 7 | extend ActiveSupport::Concern 8 | 9 | class_methods do 10 | def default_country_attribute(name, country:) 11 | define_method("#{name}=") do |value| 12 | self[name] = { country: country }.merge(value) 13 | end 14 | end 15 | end 16 | end 17 | 18 | describe JsonValidator do 19 | describe :validate_each do 20 | before do 21 | run_migration do 22 | create_table(:users, force: true) do |t| 23 | t.text :data 24 | t.json :smart_data 25 | end 26 | end 27 | 28 | spawn_model 'User' do 29 | include CountryDefaulter 30 | 31 | schema = ' 32 | { 33 | "type": "object", 34 | "properties": { 35 | "city": { "type": "string" }, 36 | "country": { "type": "string" } 37 | }, 38 | "required": ["country"] 39 | } 40 | ' 41 | 42 | default_country_attribute :smart_data, country: 'Canada' 43 | 44 | serialize :data, coder: JSON 45 | serialize :other_data, coder: JSON 46 | validates :data, json: { schema: schema, message: ->(errors) { errors } } 47 | validates :other_data, json: { schema: schema, message: ->(errors) { errors.map { |error| error['details'].to_a.flatten.join(' ') } } } 48 | validates :smart_data, json: { value: ->(record, _, _) { record[:smart_data] }, schema: schema, message: ->(errors) { errors } } 49 | 50 | def smart_data 51 | OpenStruct.new(self[:smart_data]) 52 | end 53 | end 54 | end 55 | 56 | context 'with valid JSON data but schema errors' do 57 | let(:user) do 58 | User.new( 59 | data: '{"city":"Quebec City"}', 60 | other_data: '{"city":"Quebec City"}', 61 | smart_data: { country: 'Ireland', city: 'Dublin' } 62 | ) 63 | end 64 | 65 | specify do 66 | expect(user).not_to be_valid 67 | expect(user.errors.full_messages).to eql(['Data object at root is missing required properties: country', 'Other data missing_keys country']) 68 | expect(user.errors.group_by_attribute[:data].first).to have_attributes( 69 | options: include(errors: ['object at root is missing required properties: country']) 70 | ) 71 | expect(user.errors.group_by_attribute[:other_data].first).to have_attributes( 72 | options: include(errors: ['object at root is missing required properties: country']) 73 | ) 74 | expect(user.data).to eql({ 'city' => 'Quebec City' }) 75 | expect(user.data_invalid_json).to be_nil 76 | expect(user.smart_data.city).to eql('Dublin') 77 | expect(user.smart_data.country).to eql('Ireland') 78 | end 79 | end 80 | 81 | context 'with invalid JSON data' do 82 | let(:data) { 'What? This is not JSON at all.' } 83 | let(:user) { User.new(data: data, smart_data: data) } 84 | 85 | specify do 86 | expect(user.data_invalid_json).to eql(data) 87 | expect(user.data).to eql({}) 88 | 89 | # Ensure that both setters ran 90 | expect(user.smart_data_invalid_json).to eql(data) 91 | expect(user.smart_data).to eql(OpenStruct.new({ country: 'Canada' })) 92 | end 93 | end 94 | 95 | context 'with missing country in smart data' do 96 | let(:user) do 97 | User.new( 98 | data: '{"city":"Quebec City","country":"Canada"}', 99 | other_data: '{"city":"Quebec City","country":"Canada"}', 100 | smart_data: { city: 'Quebec City' } 101 | ) 102 | end 103 | 104 | specify do 105 | expect(user).to be_valid 106 | expect(user.smart_data.city).to eql('Quebec City') 107 | expect(user.smart_data.country).to eql('Canada') # Due to CountryDefaulter 108 | end 109 | end 110 | end 111 | 112 | describe :schema do 113 | let(:validator) { JsonValidator.new(options) } 114 | let(:options) { { attributes: [:foo], schema: schema_option } } 115 | let(:schema) { validator.send(:schema, record) } 116 | 117 | context 'with String schema' do 118 | let(:schema_option) { double(:schema) } 119 | let(:record) { double(:record) } 120 | 121 | it { expect(schema).to eql(schema_option) } 122 | end 123 | 124 | context 'with Proc schema returning a Proc returning a Proc' do 125 | let(:schema_option) { -> { dynamic_schema } } 126 | let(:record) { record_class.new } 127 | let(:record_class) do 128 | Class.new do 129 | def dynamic_schema 130 | -> { another_dynamic_schema } 131 | end 132 | 133 | def another_dynamic_schema 134 | -> { what_another_dynamic_schema } 135 | end 136 | 137 | def what_another_dynamic_schema 138 | 'yay' 139 | end 140 | end 141 | end 142 | 143 | it { expect(schema).to eql('yay') } 144 | end 145 | 146 | context 'with Symbol schema' do 147 | let(:schema_option) { :dynamic_schema } 148 | let(:record) { record_class.new } 149 | let(:record_class) do 150 | Class.new do 151 | def dynamic_schema 152 | 'foo' 153 | end 154 | end 155 | end 156 | 157 | it { expect(schema).to eql('foo') } 158 | end 159 | end 160 | 161 | describe :message do 162 | let(:validator) { JsonValidator.new(options) } 163 | let(:options) { { attributes: [:foo], message: message_option } } 164 | let(:message) { validator.send(:message, errors) } 165 | let(:errors) { %i[first_error second_error] } 166 | 167 | context 'with Symbol message' do 168 | let(:message_option) { :invalid_json } 169 | it { expect(message).to eql([:invalid_json]) } 170 | end 171 | 172 | context 'with String value' do 173 | let(:message_option) { ->(errors) { errors } } 174 | it { expect(message).to eql(%i[first_error second_error]) } 175 | end 176 | end 177 | end 178 | # rubocop:enable Metrics/BlockLength 179 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path('lib', __dir__) 4 | 5 | require 'active_support/all' 6 | require 'rspec' 7 | require 'pg' 8 | 9 | require 'activerecord_json_validator' 10 | 11 | # Require our macros and extensions 12 | Dir[File.expand_path('../spec/support/macros/**/*.rb', __dir__)].map(&method(:require)) 13 | 14 | RSpec.configure do |config| 15 | # Include our macros 16 | config.include DatabaseMacros 17 | config.include ModelMacros 18 | 19 | config.before :each do 20 | adapter = ENV['DB_ADAPTER'] || 'postgresql' 21 | setup_database(adapter: adapter, database: 'activerecord_json_validator_test') 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/support/macros/database/database_adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DatabaseAdapter 4 | def initialize(opts = {}) 5 | @database = opts[:database] 6 | end 7 | 8 | def establish_connection! 9 | ActiveRecord::Base.establish_connection(ENV.fetch('DATABASE_URL', 'postgres://postgres@localhost/activerecord_json_validator_test')) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/macros/database/mysql_adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'database_adapter' 4 | 5 | class Mysql2Adapter < DatabaseAdapter 6 | def reset_database! 7 | ActiveRecord::Base.connection.execute("SELECT concat('DROP TABLE IF EXISTS ', table_name, ';') FROM information_schema.tables WHERE table_schema = '#{@database}';") 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/support/macros/database/postgresql_adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'database_adapter' 4 | 5 | class PostgresqlAdapter < DatabaseAdapter 6 | def reset_database! 7 | ActiveRecord::Base.connection.execute('drop schema public cascade;') 8 | ActiveRecord::Base.connection.execute('create schema public;') 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/support/macros/database_macros.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DatabaseMacros 4 | # Run migrations in the test database 5 | def run_migration(&block) 6 | migration_class = if ActiveRecord::Migration.respond_to?(:[]) 7 | ActiveRecord::Migration[4.2] 8 | else 9 | ActiveRecord::Migration 10 | end 11 | 12 | # Create a new migration class 13 | klass = Class.new(migration_class) 14 | 15 | # Create a new `up` that executes the argument 16 | klass.send(:define_method, :up) { instance_exec(&block) } 17 | 18 | # Create a new instance of it and execute its `up` method 19 | klass.new.up 20 | end 21 | 22 | def setup_database(opts = {}) 23 | adapter = "#{opts[:adapter].capitalize}Adapter".constantize.new(database: opts[:database]) 24 | adapter.establish_connection! 25 | adapter.reset_database! 26 | 27 | # Silence everything 28 | ActiveRecord::Base.logger = ActiveRecord::Migration.verbose = nil 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/support/macros/model_macros.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ModelMacros 4 | # Create a new model class 5 | def spawn_model(klass_name, parent_klass = ActiveRecord::Base, &block) 6 | Object.instance_eval { remove_const klass_name } if Object.const_defined?(klass_name) 7 | Object.const_set(klass_name, Class.new(parent_klass)) 8 | Object.const_get(klass_name).class_eval(&block) if block_given? 9 | end 10 | end 11 | --------------------------------------------------------------------------------