Skip to content

Commit

Permalink
Add dry-validation contract support (#238)
Browse files Browse the repository at this point in the history
  • Loading branch information
ryanwjackson authored and pkuczynski committed Jul 25, 2019
1 parent 5658867 commit f2b8d2a
Show file tree
Hide file tree
Showing 8 changed files with 122 additions and 40 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### New features

* Add dry-validation contract support ([#238](https://github.com/railsconfig/config/pull/238))

### Changes

* Get rid of activesupport dependency ([#230](https://github.com/railsconfig/config/pull/230))
Expand Down
49 changes: 41 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,26 +292,59 @@ Check [Deep Merge](https://github.com/danielsdeleo/deep_merge) for more details.

### Validation

With Ruby 2.1 or newer, you can optionally define a schema to validate presence (and type) of specific config values:
With Ruby 2.1 or newer, you can optionally define a [schema](https://github.com/dry-rb/dry-schema) or [contract](https://github.com/dry-rb/dry-validation) (added in `config-2.1`) using [dry-rb](https://github.com/dry-rb) to validate presence (and type) of specific config values. Generally speaking contracts allow to describe more complex validations with depencecies between fields.

If you provide either validation option (or both) it will automatically be used to validate your config. If validation fails it will raise a `Config::Validation::Error` containing information about all the mismatches between the schema and your config.

Both examples below demonstrates how to ensure that the configuration has an optional `email` and the `youtube` structure with the `api_key` field filled. The contract adds an additional rule.

#### Contract

Leverage dry-validation, you can create a contract with a params schema and rules:

```ruby
class ConfigContract < Dry::Validation::Contract
params do
optional(:email).maybe(:str?)
required(:youtube).schema do
required(:api_key).filled
end
end
rule(:email) do
unless /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i.match?(value)
key.failure('has invalid format')
end
end
end
Config.setup do |config|
config.validation_contract = ConfigContract
end
```

The above example adds a rule to ensure the `email` is valid by matching it against the provided regular expression.

Check [dry-validation](https://github.com/dry-rb/dry-validation) for more details.

#### Schema

You may also specify a schema using [dry-schema](https://github.com/dry-rb/dry-schema):

```ruby
Config.setup do |config|
# ...
config.schema do
optional(:email).maybe(:str?)
required(:youtube).schema do
required(:api_key).filled
end
end
end
```

The above example demonstrates how to ensure that the configuration has the `youtube` structure
with the `api_key` field filled.

If you define a schema it will automatically be used to validate your config. If validation fails it will
raise a `Config::Validation::Error` containing a nice message with information about all the mismatches
between the schema and your config.

Check [dry-schema](https://github.com/dry-rb/dry-schema) for more details.

### Missing keys
Expand Down
2 changes: 1 addition & 1 deletion config.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Please consider donating to our open collective to help us maintain this project
s.required_ruby_version = '>= 2.4.0'

s.add_dependency 'deep_merge', '~> 1.2', '>= 1.2.1'
s.add_dependency 'dry-schema', '~> 1.0'
s.add_dependency 'dry-validation', '~> 1.0'

s.add_development_dependency 'rake', '~> 12.0', '>= 12.0.0'

Expand Down
4 changes: 3 additions & 1 deletion lib/config.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
require 'dry-validation'
require 'config/compatibility'
require 'config/options'
require 'config/configuration'
Expand All @@ -23,7 +24,8 @@ module Config
knockout_prefix: nil,
merge_nil_values: true,
overwrite_arrays: true,
merge_hash_arrays: false
merge_hash_arrays: false,
validation_contract: nil
)

def self.setup
Expand Down
2 changes: 0 additions & 2 deletions lib/config/validation/schema.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
require 'dry-schema'

module Config
module Validation
module Schema
Expand Down
21 changes: 13 additions & 8 deletions lib/config/validation/validate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,23 @@
module Config
module Validation
module Validate

def validate!
if Config.schema
v_res = Config.schema.(self.to_hash)
validate_using!(Config.validation_contract)
validate_using!(Config.schema)
end

private

unless v_res.success?
error = Config::Validation::Error.format(v_res)
raise Config::Validation::Error.new("Config validation failed:\n\n#{error}")
end
def validate_using!(validator)
if validator
result = validator.call(to_hash)

return if result.success?

error = Config::Validation::Error.format(result)
raise Config::Validation::Error, "Config validation failed:\n\n#{error}"
end
end

end
end
end
11 changes: 6 additions & 5 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,12 @@

# Extend Config module with ability to reset configuration to the default values
def self.reset
self.const_name = 'Settings'
self.use_env = false
self.knockout_prefix = nil
self.overwrite_arrays = true
self.schema = nil
self.const_name = 'Settings'
self.use_env = false
self.knockout_prefix = nil
self.overwrite_arrays = true
self.schema = nil
self.validation_contract = nil
instance_variable_set(:@_ran_once, false)
end
end
Expand Down
69 changes: 54 additions & 15 deletions spec/validation_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,35 +8,74 @@
Config.reset
end

it 'should raise if schema is present and validation fails' do
Config.setup do |config|
config.schema do
context 'with validation_contract' do
it 'should raise if validation_contract is present and validation fails' do
contract = Class.new(Dry::Validation::Contract)
contract.params do
required(:youtube).schema do
required(:nonexist_field).filled
required(:multiple_requirements).filled(:integer, gt?: 18)
end
end
end
Config.setup do |config|
config.validation_contract = contract.new
end

msg = "Config validation failed:\n\n"
msg += " youtube.nonexist_field: is missing\n"
msg += ' youtube.multiple_requirements: must be an integer'
msg = "Config validation failed:\n\n"
msg += " youtube.nonexist_field: is missing\n"
msg += ' youtube.multiple_requirements: must be an integer'

expect { Config.load_files("#{fixture_path}/validation/config.yml") }.
to raise_error(Config::Validation::Error, Regexp.new(msg))
end
expect { Config.load_files("#{fixture_path}/validation/config.yml") }.
to raise_error(Config::Validation::Error, Regexp.new(msg))
end

it 'should work if validation passes' do
Config.setup do |config|
config.schema do
it 'should work if validation passes' do
contract = Class.new(Dry::Validation::Contract)
contract.params do
required(:youtube).schema do
required(:api_key).filled
end
end
Config.setup do |config|
config.validation_contract = contract.new
end

expect { Config.load_files("#{fixture_path}/validation/config.yml") }.
to_not raise_error
end
end

expect { Config.load_files("#{fixture_path}/validation/config.yml") }.
to_not raise_error
context 'with schema' do
it 'should raise if schema is present and validation fails' do
Config.setup do |config|
config.schema do
required(:youtube).schema do
required(:nonexist_field).filled
required(:multiple_requirements).filled(:integer, gt?: 18)
end
end
end

msg = "Config validation failed:\n\n"
msg += " youtube.nonexist_field: is missing\n"
msg += ' youtube.multiple_requirements: must be an integer'

expect { Config.load_files("#{fixture_path}/validation/config.yml") }.
to raise_error(Config::Validation::Error, Regexp.new(msg))
end

it 'should work if validation passes' do
Config.setup do |config|
config.schema do
required(:youtube).schema do
required(:api_key).filled
end
end
end

expect { Config.load_files("#{fixture_path}/validation/config.yml") }.
to_not raise_error
end
end
end
end

0 comments on commit f2b8d2a

Please sign in to comment.