├── .gitignore ├── .rspec ├── .ruby-version ├── .travis.yml ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── nxt_schema.rb └── nxt_schema │ ├── callable.rb │ ├── dsl.rb │ ├── error.rb │ ├── errors │ ├── coercion_error.rb │ ├── invalid.rb │ └── invalid_options.rb │ ├── node.rb │ ├── node │ ├── any_of.rb │ ├── base.rb │ ├── collection.rb │ ├── error_store.rb │ ├── errors │ │ ├── schema_error.rb │ │ └── validation_error.rb │ ├── leaf.rb │ └── schema.rb │ ├── registry.rb │ ├── registry │ └── proxy.rb │ ├── template │ ├── any_of.rb │ ├── base.rb │ ├── collection.rb │ ├── has_sub_nodes.rb │ ├── leaf.rb │ ├── maybe_evaluator.rb │ ├── on_evaluator.rb │ ├── schema.rb │ ├── sub_nodes.rb │ ├── type_resolver.rb │ └── type_system_resolver.rb │ ├── types.rb │ ├── undefined.rb │ ├── validators │ ├── attribute.rb │ ├── equal_to.rb │ ├── error_messages.rb │ ├── error_messages │ │ └── en.yaml │ ├── excluded_in.rb │ ├── excludes.rb │ ├── greater_than.rb │ ├── greater_than_or_equal.rb │ ├── included_in.rb │ ├── includes.rb │ ├── less_than.rb │ ├── less_than_or_equal.rb │ ├── optional_node.rb │ ├── pattern.rb │ ├── query.rb │ ├── registry.rb │ ├── validate_with_proxy.rb │ └── validator.rb │ └── version.rb ├── nxt_schema.gemspec └── spec ├── dsl_spec.rb ├── merging_schemas_spec.rb ├── node ├── benchmark_spec.rb ├── context_spec.rb ├── maybe_evaluator_spec.rb ├── on_evaluator_spec.rb ├── register_as_coerced_spec.rb └── validations │ ├── equal_to_spec.rb │ ├── optional_node_spec.rb │ ├── validate_with_spec.rb │ └── within_any_of_node_spec.rb ├── registry_spec.rb ├── spec_helper.rb ├── template ├── apply_spec.rb ├── callable_type_spec.rb ├── has_sub_nodes │ ├── any_of │ │ ├── any_of_collection_of_schemas_spec.rb │ │ ├── collection_spec.rb │ │ ├── leaf_spec.rb │ │ └── schema_spec.rb │ ├── collection_spec.rb │ ├── schema │ │ ├── additional_keys_strategy │ │ │ ├── allow_spec.rb │ │ │ ├── reject_spec.rb │ │ │ └── restrict_spec.rb │ │ ├── constructor_spec.rb │ │ ├── input_preprocessor_spec.rb │ │ ├── omni_present_nodes_spec.rb │ │ ├── optional_nodes_spec.rb │ │ └── transform_keys_spec.rb │ └── schema_spec.rb └── path_spec.rb └── type_resolver_spec.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | vendor/cache/ 10 | .idea/ 11 | 12 | # rspec failure tracking 13 | .rspec_status 14 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.1.3 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | sudo: false 3 | language: ruby 4 | cache: bundler 5 | rvm: 6 | - 3.1.3 7 | before_install: gem install bundler -v 1.17.2 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 4 | # Specify your gem's dependencies in nxt_schema.gemspec 5 | gemspec 6 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | nxt_schema (1.0.2) 5 | activesupport 6 | dry-types 7 | nxt_init 8 | nxt_registry 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | activesupport (6.1.4.1) 14 | concurrent-ruby (~> 1.0, >= 1.0.2) 15 | i18n (>= 1.6, < 2) 16 | minitest (>= 5.1) 17 | tzinfo (~> 2.0) 18 | zeitwerk (~> 2.3) 19 | coderay (1.1.3) 20 | concurrent-ruby (1.1.9) 21 | diff-lcs (1.5.0) 22 | dry-configurable (0.12.1) 23 | concurrent-ruby (~> 1.0) 24 | dry-core (~> 0.5, >= 0.5.0) 25 | dry-container (0.7.2) 26 | concurrent-ruby (~> 1.0) 27 | dry-configurable (~> 0.1, >= 0.1.3) 28 | dry-core (0.5.0) 29 | concurrent-ruby (~> 1.0) 30 | dry-inflector (0.2.0) 31 | dry-logic (1.1.0) 32 | concurrent-ruby (~> 1.0) 33 | dry-core (~> 0.5, >= 0.5) 34 | dry-types (1.5.1) 35 | concurrent-ruby (~> 1.0) 36 | dry-container (~> 0.3) 37 | dry-core (~> 0.5, >= 0.5) 38 | dry-inflector (~> 0.1, >= 0.1.2) 39 | dry-logic (~> 1.0, >= 1.0.2) 40 | hirb (0.7.3) 41 | i18n (1.8.10) 42 | concurrent-ruby (~> 1.0) 43 | method_profiler (2.0.1) 44 | hirb (>= 0.6.0) 45 | method_source (1.0.0) 46 | minitest (5.14.4) 47 | nxt_init (0.1.5) 48 | activesupport 49 | nxt_registry (0.3.10) 50 | activesupport 51 | pry (0.14.1) 52 | coderay (~> 1.1) 53 | method_source (~> 1.0) 54 | rake (12.3.3) 55 | rspec (3.11.0) 56 | rspec-core (~> 3.11.0) 57 | rspec-expectations (~> 3.11.0) 58 | rspec-mocks (~> 3.11.0) 59 | rspec-core (3.11.0) 60 | rspec-support (~> 3.11.0) 61 | rspec-expectations (3.11.0) 62 | diff-lcs (>= 1.2.0, < 2.0) 63 | rspec-support (~> 3.11.0) 64 | rspec-mocks (3.11.1) 65 | diff-lcs (>= 1.2.0, < 2.0) 66 | rspec-support (~> 3.11.0) 67 | rspec-support (3.11.0) 68 | tzinfo (2.0.4) 69 | concurrent-ruby (~> 1.0) 70 | zeitwerk (2.4.2) 71 | 72 | PLATFORMS 73 | ruby 74 | 75 | DEPENDENCIES 76 | bundler (~> 1.17) 77 | method_profiler 78 | nxt_schema! 79 | pry 80 | rake (~> 12.3.3) 81 | rspec (~> 3.0) 82 | 83 | BUNDLED WITH 84 | 1.17.2 85 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Andreas Robecke 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NxtSchema 2 | 3 | ## Installation 4 | 5 | Add this line to your application's Gemfile: 6 | 7 | ```ruby 8 | gem 'nxt_schema' 9 | ``` 10 | 11 | And then execute: 12 | 13 | $ bundle 14 | 15 | Or install it yourself as: 16 | 17 | $ gem install nxt_schema 18 | 19 | ## What is it for? 20 | 21 | NxtSchema is a type coercion and validation framework that allows you to coerce and validate arbitrary nested 22 | structures of data. The original idea is taken from https://dry-rb.org/gems/dry-schema and 23 | https://dry-rb.org/gems/dry-validation from the amazing dry.rb eco system. In contrast to dry-schema, 24 | NxtSchema aims to be a simpler solution that hopefully is easier to understand and debug. 25 | It also ships with some handy features that dry-schema does not implement. 26 | 27 | ### Usage 28 | 29 | ```ruby 30 | PERSON = NxtSchema.schema(:person) do 31 | node(:first_name, :String) 32 | node(:last_name, :String) 33 | node(:email, :String, optional: true).validate(:includes, '@') 34 | end 35 | 36 | input = { 37 | first_name: 'Ändy', 38 | last_name: 'Robecke', 39 | email: 'andreas@robecke.de' 40 | } 41 | 42 | result = PERSON.apply(input: input) 43 | 44 | result.valid? # => true 45 | result.output # => input 46 | ``` 47 | 48 | ### Nodes 49 | 50 | A schema consists of a number of nodes. Every node has a name and an associated type for casting it's input when the 51 | schema is applied. Schemas can consist of 4 different kinds of nodes: 52 | 53 | ```ruby 54 | NxtSchema::Node::Schema # => Hash of values 55 | NxtSchema::Node::Collection # => Array of values 56 | NxtSchema::Node::AnyOf # => Any of the defined schemas 57 | NxtSchema::Node::Leaf # => Node without sub nodes 58 | ``` 59 | 60 | The kind of node dictates how the schema is applied to the input. On the root level the following methods are available 61 | to create schemas: 62 | 63 | ```ruby 64 | NxtSchema.schema { ... } # => Creates a schema node 65 | NxtSchema.collection { ... } # => Creates an array of nodes 66 | NxtSchema.any_of { ... } # => Creates a collection of allowed schemas 67 | ``` 68 | 69 | #### Node predicate aliases 70 | 71 | Of course these nodes can be combined and nested in arbitrary manner. When defining nodes within a schema, nodes are 72 | always required per default. You can create nodes with the node method or several useful helper methods. 73 | 74 | ```ruby 75 | NxtSchema.schema(:person) do 76 | required(:first_name, :String) # => same as node(:first_name, :String) 77 | optional(:last_name, :String) # => same as node(:first_name, :String, optional: true) 78 | omnipresent(:email, :String) # => same as node(:first_name, :String, omnipresent: true) 79 | end 80 | ``` 81 | 82 | **NOTE: The methods above only apply to the keys of your schema and do not make any assumptions about values!** 83 | 84 | In other word this means that making a node optional only makes your node optional. When your input contains the key but 85 | the value is nil, you will still get an error in case there is no default or maybe expression that applies. Omnipresent 86 | node also only inject the node into the schema but do not inject a default value. In order to inject a key with value 87 | into a schema you also have to combine the node predicates with default value method described below. For clarification 88 | check out the examples below: 89 | 90 | ```ruby 91 | # Optional node without default value 92 | 93 | schema = NxtSchema.schema(:person) do 94 | optional(:email, :String) 95 | end 96 | 97 | result = schema.apply(input: { email: nil }) 98 | result.errors # => {"person.email"=>["nil violates constraints (type?(String, nil) failed)"]} 99 | result.output # => {:email=>nil} 100 | 101 | result = schema.apply(input: {}) 102 | result.errors # => {} 103 | result.output # => {} 104 | ``` 105 | 106 | ```ruby 107 | # Optional node with default value 108 | 109 | schema = NxtSchema.schema(:person) do 110 | optional(:email, :String).default('andreas@robecke.de') 111 | end 112 | 113 | result = schema.apply(input: { email: nil }) 114 | result.errors # => {} 115 | result.output # => {:email=>"andreas@robecke.de"} 116 | 117 | result = schema.apply(input: {}) 118 | result.errors # => {} 119 | result.output # => {} 120 | ``` 121 | 122 | ```ruby 123 | # Omnipresent node without default value 124 | 125 | schema = NxtSchema.schema(:person) do 126 | omnipresent(:email, :String) 127 | end 128 | 129 | result = schema.apply(input: {}) 130 | result.errors # => {} 131 | result.output # => {:email=>NxtSchema::Undefined} 132 | ``` 133 | 134 | ```ruby 135 | # Omnipresent node with default value and maybe expression to allow default value to break type contract. 136 | 137 | schema = NxtSchema.schema(:person) do 138 | omnipresent(:email, :String).default(nil).maybe(:nil?) 139 | end 140 | 141 | result = schema.apply(input: {}) 142 | result.errors # => {} 143 | result.output # => {:email=>nil} 144 | 145 | result = schema.apply(input: { email: 'andreas@robecke.de' }) 146 | result.errors # => {} 147 | result.output # => {:email=>"andreas@robecke.de"} 148 | ``` 149 | 150 | ##### Conditionally optional nodes 151 | 152 | You can also pass a proc as the optional option. This is a shortcut for adding a validation to the parent node 153 | that will result in a validation error in case the optional condition does not apply and the parent node does not 154 | contain a sub node with that name (here contact schema not including an email node). 155 | 156 | ```ruby 157 | schema = NxtSchema.schema(:contact) do 158 | required(:first_name, :String) 159 | required(:last_name, :String) 160 | node(:email, :String, optional: ->(node) { node.up[:last_name].input == 'Robecke' }) 161 | end 162 | 163 | result = schema.apply(input: { first_name: 'Andy', last_name: 'Other' }) 164 | result.errors # => {"contact"=>["Required key :email is missing"]} 165 | 166 | result = schema.apply(input: { first_name: 'Andy', last_name: 'Robecke' }) 167 | result.errors # => {} 168 | ``` 169 | 170 | #### Combining Schemas 171 | 172 | You can also simply reuse a schema by passing it to the node method as the type of a node. When doing so the schema 173 | will be cloned with the same options and configuration as the schema passed in. 174 | 175 | ```ruby 176 | ADDRESS = NxtSchema.schema(:address) do 177 | required(:street, :String) 178 | required(:town, :String) 179 | required(:zip_code, :String) 180 | end 181 | 182 | PERSON = NxtSchema.schema(:person) do 183 | required(:first_name, :String) 184 | required(:last_name, :String) 185 | optional(:address, ADDRESS) 186 | end 187 | ``` 188 | 189 | ### Types 190 | 191 | The type system is built with dry-types from the amazing https://dry-rb.org eco system. Even though dry-types also 192 | offers features such as default values for types as well as maybe types, these features are built directly into 193 | NxtSchema. 194 | 195 | In NxtSchema every node has a type and you can either provide a symbol that will be resolved 196 | through the type system of the schema or you can directly provide an instance of dry type and thus use your 197 | custom types. This means you can basically build any kind of objects such as structs and models from your data and 198 | you are not limited to just hashes arrays and primitives. 199 | 200 | #### Default type system 201 | 202 | You can tell your schema which default type system it should use. Dry-Types comes with a few built in type systems. 203 | Per default NxtSchema will use nominal types if not specified otherwise. If the type cannot be resolved from the default 204 | type system that was specified NxtSchema will always fallback to nominal types. In theory you can provide 205 | a separate type system per node if that's what you need. 206 | 207 | ```ruby 208 | NxtSchema.schema do 209 | required(:test, :String) # The :String will resolve to NxtSchema::Types::Nominal::String 210 | end 211 | 212 | NxtSchema.schema(type_system: NxtSchema::Types::JSON) do 213 | required(:test, :Date) # The :Date will resolve to NxtSchema::Types::JSON::Date 214 | # When the type does not exist in the default type system (there is non JSON::String) we fallback to nominal types 215 | required(:test, :String) 216 | end 217 | ``` 218 | 219 | #### NxtSchema.params 220 | 221 | NxtSchema.params will give you a schema as root node with NxtSchema::Types::Params as default type system. 222 | This is suitable to validate and coerce your query params. 223 | 224 | ```ruby 225 | NxtSchema.params do 226 | required(:effective_at, :DateTime) # would resolve to Types::Params::DateTime 227 | required(:test, :String) # The :String will resolve to NxtSchema::Types::Nominal::String 228 | required(:advanced, NxtSchema::Types::Registry::Bool) # long version of required(:advanced, :Bool) 229 | end 230 | ``` 231 | 232 | #### NxtSchema::Registry 233 | 234 | To make use of NxtSchema.params in your controller you can simply include the `NxtSchema::Registry` to easily register 235 | and apply schemas: 236 | 237 | ```ruby 238 | class UsersController < ApplicationController 239 | include NxtSchema::Registry 240 | 241 | # register the schema for the :create action 242 | schemas.register( 243 | :create, 244 | NxtSchema.params do 245 | required(:first_name, :String) 246 | required(:last_name, :String) 247 | end 248 | ) 249 | 250 | def create 251 | User.create!(**create_params) 252 | end 253 | 254 | private 255 | 256 | def create_params 257 | # apply the registered schema 258 | schemas.apply!(:create, params.fetch(:user)) 259 | end 260 | end 261 | 262 | ``` 263 | 264 | #### Custom types 265 | 266 | You can also register custom types. In order to check out all the cool things you can do with dry types you should 267 | check out dry-types on https://dry-rb.org. But here is how you can add a type to the `NxtSchema::Types` module. 268 | 269 | ```ruby 270 | NxtSchema.register_type( 271 | :MyCustomStrippedString, 272 | NxtSchema::Types::Strict::String.constructor(->(string) { string&.strip }) 273 | ) 274 | 275 | # once registered you can use the type in your schema 276 | 277 | NxtSchema.schema(:company) do 278 | required(:name, :MyCustomStrippedString) 279 | end 280 | ``` 281 | 282 | ### Values 283 | 284 | #### Default values 285 | 286 | ```ruby 287 | # Define default values with the default method 288 | required(:test, :DateTime).default(nil) 289 | required(:test, :DateTime).default(-> { Time.current }) 290 | ``` 291 | 292 | #### Maybe values 293 | 294 | With maybe expressions you can halt coercion and allow your values to break the type contract. 295 | **Note: This means that your output will simply be set to the input without coercing the value!** 296 | 297 | ```ruby 298 | # Define maybe values (values that do not match the type) 299 | required(:test, :String).maybe(:nil?) 300 | 301 | nodes(:tests).maybe(:empty?) do # will allow the collection to be empty and thus not contain strings 302 | required(:test, :String) 303 | end 304 | 305 | ``` 306 | 307 | ### Validations 308 | 309 | NxtSchema comes with a simple validation system and ships with a small set of useful validators. Every node in a schema 310 | implements the `:validate` method. Similar to ActiveModel::Validations it allows you to simply add errors to a node 311 | based on some condition. When the node is yielded to your validation proc you have access to the nodes input with 312 | `node.input` and `node.index` when the node is within a collection of nodes as well as `node.name`. Furthermore you have 313 | access to the context that was passed in when defining the schema or passed to the apply method later. 314 | 315 | **NOTE: Validations only run when no maybe expression applies and the node input could be coerced successfully** 316 | 317 | ```ruby 318 | # Simple custom validation 319 | required(:test, :String).validate(-> (node) { node.add_error("#{node.input} is not valid") if node.input == 'not allowed' }) 320 | # Built in validations 321 | required(:test, :String).validate(:attribute, :size, ->(s) { s < 7 }) 322 | required(:test, :String).validate(:equal_to, 'same') 323 | required(:test, :String).validate(:excluded_in, %w[not_allowed]) # excluded in the target: %w[not_allowed] 324 | required(:test, :String).validate(:included_in, %w[allowed]) # included in the target: %w[allowed] 325 | required(:test, :Array).validate(:excludes, 'excluded') # array value itself must exclude 'excluded' 326 | required(:test, :Array).validate(:includes, 'included') # array value itself must include 'included' 327 | required(:test, :Integer).validate(:greater_than, 1) 328 | required(:test, :Integer).validate(:greater_than_or_equal, 1) 329 | required(:test, :Integer).validate(:less_than, 1) 330 | required(:test, :Integer).validate(:less_than_or_equal, 1) 331 | required(:test, :String).validate(:pattern, /\A.*@.*\z/) 332 | required(:test, :String).validate(:query, :present?) 333 | ``` 334 | 335 | #### Custom validators 336 | 337 | You can also register your custom validators. Therefore you can simply implement a class that returns a lambda on build. 338 | This lambda will be called with the node the validations runs on and it's input value. Except this, you are free in 339 | how your validator can be used. Check out the specs for some examples. 340 | 341 | ```ruby 342 | class MyCustomExclusionValidator 343 | def initialize(target) 344 | @target = target 345 | end 346 | 347 | attr_reader :target 348 | 349 | def build 350 | lambda do |node, value| 351 | if target.exclude?(value) 352 | true 353 | else 354 | node.add_error("#{target} should not contain #{value}") 355 | false # validators must return false in the bad case (add_error already does this as per default) 356 | end 357 | end 358 | end 359 | end 360 | 361 | # Register your validators 362 | NxtSchema.register_validator(MyCustomExclusionValidator, :my_custom_exclusion_validator) 363 | 364 | # and then simply reference it with the key you've registered it 365 | schema = NxtSchema.schema(:company) do 366 | requires(:name, :String).validate(:my_custom_exclusion_validator, %w[lemonade]) 367 | end 368 | 369 | schema.apply(name: 'lemonade').valid? # => false 370 | ``` 371 | 372 | #### Validation messages 373 | 374 | - Allow to specify a path to translations 375 | - Add translated errors 376 | - Interpolate with actual vs. expected 377 | 378 | #### Combining validators 379 | 380 | `node(:test, String).validate(...)` basically adds a validator to the node. Of course you can add multiple validators. 381 | But that means that they will all be executed. If you want your validator to only run in case 382 | another was false, you can use `:validat_with do ... end` in order to combine validators based on custom logic. 383 | 384 | ```ruby 385 | NxtSchema.schema do 386 | required(:test, :Integer).validate_with do 387 | validator(:greater_than, 5) && 388 | validator(:greater_than, 6) || 389 | validator(:greater_than, 7) 390 | end 391 | end 392 | ``` 393 | 394 | Note that this will not run subsequent validators once one was valuated to false and thus might not contain all error 395 | messages of all validators that would have failed. 396 | 397 | 398 | ### Schema options 399 | 400 | #### Optional keys strategies 401 | 402 | Schemas in NxtSchema only look at the keys that you have defined in your schema, others are ignored per default. 403 | You can change this behaviour by providing a strategy for the `:additional_keys` option. 404 | 405 | ```ruby 406 | # This will simply ignore any other key except test 407 | NxtSchema.schema(additional_keys: :ignore) do 408 | required(:test, :String) 409 | end 410 | 411 | # This would give you an error in case you apply anything other than { test: '...' } 412 | NxtSchema.schema(additional_keys: :restrict) do 413 | required(:test, :String) 414 | end 415 | 416 | # This will merge other keys into your output 417 | schema = NxtSchema.schema(additional_keys: :allow) do 418 | required(:test, :String) 419 | end 420 | 421 | schema.apply(input: {test: 'getsafe', other: 'Heidelberg'}) 422 | schema.valid? # => true 423 | schema.value # => { test: 'getsafe', other: 'Heidelberg' } 424 | ``` 425 | 426 | #### Transform keys 427 | 428 | To transform the keys of your output simply specify the transform_output_keys option. This might be useful 429 | when you want your schema to return only symbolized keys for example. 430 | 431 | ```ruby 432 | schema = NxtSchema.schema(transform_output_keys: ->(key) { key.to_sym }) do 433 | required(:test, :String) 434 | end 435 | 436 | schema.apply(input: { 'test' => 'getsafe' }) # => {:test=>"getsafe"} 437 | schema.apply(input: { test: 'getsafe' }) # => {:test=>"getsafe"} 438 | ``` 439 | 440 | #### Adding meta data to nodes 441 | 442 | You want to give nodes an ID or some other meta data? You can use the meta method on nodes for adding additional 443 | information onto any node. 444 | 445 | ```ruby 446 | schema = NxtSchema.schema do 447 | ERROR_MESSAGES = { 448 | test: 'This is always broken' 449 | } 450 | 451 | required(:test, :String).meta(ERROR_MESSAGES).validate ->(node) { node.add_error(node.meta.fetch(node.name)) } 452 | end 453 | 454 | schema.apply(input: { test: 'getsafe' }) 455 | schema.error # {"root.test"=>["This is always broken"]} 456 | ``` 457 | 458 | #### Contexts 459 | 460 | When defining a schema it is possible to pass in a context option. This can be anything that you would like to access 461 | during building your schema. A context could provide custom validators or default values depending of the name of your 462 | nodes for instance. 463 | 464 | ##### Build time 465 | 466 | ```ruby 467 | context = OpenStruct.new(email_validator: ->(node) { node.input && node.input.includes?('@') }) 468 | 469 | NxtSchema.schema(:developers, context: context) do 470 | required(:first_name, :String) 471 | required(:last_name, :String) 472 | required(:email, :String).validate(context.email_validator) 473 | end 474 | ``` 475 | 476 | ##### Apply time 477 | 478 | You can also pass in a context at apply time. If you do not pass in a specific 479 | context at apply time you can still access the context passed in at build time. 480 | Basically passing in a context at apply time will overwrite the context from before. You can access it simply through 481 | the node. 482 | 483 | ```ruby 484 | build_context = OpenStruct.new(email_validator: ->(node) { node.input.includes?('@') }) 485 | apply_context = OpenStruct.new(default_role: 'BOSS') 486 | 487 | schema = NxtSchema.schema(:developers, context: build_context) do 488 | # context at build time 489 | required(:email, :String).validate(context.email_validator) # 490 | # access the context at apply time through the node 491 | required(:role, :String).default { |_, node| node.context.default_role } 492 | end 493 | 494 | schema.apply(input: input, context: apply_context) 495 | ``` 496 | 497 | ## Development 498 | 499 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 500 | 501 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 502 | 503 | ## Contributing 504 | 505 | Bug reports and pull requests are welcome on GitHub at https://github.com/getand. 506 | 507 | ## License 508 | 509 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 510 | 511 | ## TODO: 512 | 513 | - Explain node interface 514 | - Add apply! method to readme 515 | - Allow to disable validation when applying 516 | --> Are there attributes that should be moved to apply time? 517 | - Should we have a global and a local registry for validators? 518 | --> Would be cool to register things for the schema only 519 | --> Would be cool if this was extendable 520 | - Do we need all off in order to combine multiple schemas? 521 | - Allow custom errors 522 | - Spec inheritance of params 523 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "nxt_schema" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/nxt_schema.rb: -------------------------------------------------------------------------------- 1 | require 'nxt_schema/version' 2 | require 'active_support/all' 3 | require 'dry-types' 4 | require 'nxt_registry' 5 | require 'nxt_init' 6 | require 'yaml' 7 | 8 | require_relative 'nxt_schema/types' 9 | require_relative 'nxt_schema/callable' 10 | require_relative 'nxt_schema/node' 11 | require_relative 'nxt_schema/undefined' 12 | require_relative 'nxt_schema/error' 13 | require_relative 'nxt_schema/errors/invalid' 14 | require_relative 'nxt_schema/errors/invalid_options' 15 | require_relative 'nxt_schema/errors/coercion_error' 16 | 17 | require_relative 'nxt_schema/validators/registry' 18 | require_relative 'nxt_schema/validators/validate_with_proxy' 19 | require_relative 'nxt_schema/validators/error_messages' 20 | require_relative 'nxt_schema/validators/validator' 21 | require_relative 'nxt_schema/validators/attribute' 22 | require_relative 'nxt_schema/validators/equal_to' 23 | require_relative 'nxt_schema/validators/optional_node' 24 | require_relative 'nxt_schema/validators/greater_than' 25 | require_relative 'nxt_schema/validators/greater_than_or_equal' 26 | require_relative 'nxt_schema/validators/less_than' 27 | require_relative 'nxt_schema/validators/less_than_or_equal' 28 | require_relative 'nxt_schema/validators/pattern' 29 | require_relative 'nxt_schema/validators/included_in' 30 | require_relative 'nxt_schema/validators/includes' 31 | require_relative 'nxt_schema/validators/excluded_in' 32 | require_relative 'nxt_schema/validators/excludes' 33 | require_relative 'nxt_schema/validators/query' 34 | 35 | require_relative 'nxt_schema/template/on_evaluator' 36 | require_relative 'nxt_schema/template/maybe_evaluator' 37 | require_relative 'nxt_schema/template/type_resolver' 38 | require_relative 'nxt_schema/template/type_system_resolver' 39 | require_relative 'nxt_schema/template/base' 40 | require_relative 'nxt_schema/template/sub_nodes' 41 | require_relative 'nxt_schema/template/has_sub_nodes' 42 | require_relative 'nxt_schema/template/any_of' 43 | require_relative 'nxt_schema/template/collection' 44 | require_relative 'nxt_schema/template/schema' 45 | require_relative 'nxt_schema/template/leaf' 46 | 47 | require_relative 'nxt_schema/node/errors/schema_error' 48 | require_relative 'nxt_schema/node/errors/validation_error' 49 | require_relative 'nxt_schema/node/error_store' 50 | require_relative 'nxt_schema/node/base' 51 | require_relative 'nxt_schema/node/any_of' 52 | require_relative 'nxt_schema/node/leaf' 53 | require_relative 'nxt_schema/node/collection' 54 | require_relative 'nxt_schema/node/schema' 55 | require_relative 'nxt_schema/dsl' 56 | require_relative 'nxt_schema/registry/proxy' 57 | require_relative 'nxt_schema/registry' 58 | 59 | module NxtSchema 60 | extend Dsl 61 | 62 | def register_error_messages(*paths) 63 | Validators::ErrorMessages.load(paths) 64 | end 65 | 66 | def register_validator(validator, *keys) 67 | keys.each { |key| NxtSchema::Validators::REGISTRY.register(key, validator) } 68 | end 69 | 70 | def register_type(key, type) 71 | NxtSchema::Types.registry(:types).register(key, type) 72 | end 73 | 74 | # Load default messages 75 | Validators::ErrorMessages.load 76 | 77 | module_function :register_error_messages, :register_validator, :register_type 78 | end 79 | -------------------------------------------------------------------------------- /lib/nxt_schema/callable.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | class Callable 3 | def initialize(callable, target = nil, *args) 4 | @callable = callable 5 | @target = target 6 | @args = args 7 | end 8 | 9 | def call 10 | return callable if value? 11 | return callable.call(*args_from_arity) if proc? 12 | 13 | target.send(callable, *args_from_arity) 14 | end 15 | 16 | def method? 17 | @method ||= callable.class.in?([Symbol, String]) && target.respond_to?(callable) 18 | end 19 | 20 | def proc? 21 | @proc ||= callable.respond_to?(:call) 22 | end 23 | 24 | def value? 25 | !method? && !proc? 26 | end 27 | 28 | private 29 | 30 | attr_reader :callable, :target, :args 31 | 32 | def arity 33 | proc? ? callable.arity : 0 34 | end 35 | 36 | def args_from_arity 37 | @args_from_arity ||= ([target] + args).take(arity) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/nxt_schema/dsl.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Dsl 3 | DEFAULT_OPTIONS = { type_system: NxtSchema::Types }.freeze 4 | 5 | def collection(name = :root, type: NxtSchema::Template::Collection::DEFAULT_TYPE, **options, &block) 6 | NxtSchema::Template::Collection.new( 7 | name: name, 8 | type: type, 9 | parent_node: nil, 10 | **DEFAULT_OPTIONS.merge(options), 11 | &block 12 | ) 13 | end 14 | 15 | alias nodes collection 16 | 17 | def schema(name = :roots, type: NxtSchema::Template::Schema::DEFAULT_TYPE, **options, &block) 18 | NxtSchema::Template::Schema.new( 19 | name: name, 20 | type: type, 21 | parent_node: nil, 22 | **DEFAULT_OPTIONS.merge(options), 23 | &block 24 | ) 25 | end 26 | 27 | def any_of(name = :roots, **options, &block) 28 | NxtSchema::Template::AnyOf.new( 29 | name: name, 30 | parent_node: nil, 31 | **DEFAULT_OPTIONS.merge(options), 32 | &block 33 | ) 34 | end 35 | 36 | # schema root with NxtSchema::Types::Params type system 37 | 38 | def params(name = :params, type: NxtSchema::Template::Schema::DEFAULT_TYPE, **options, &block) 39 | NxtSchema::Template::Schema.new( 40 | name: name, 41 | type: type, 42 | parent_node: nil, 43 | **options.merge(type_system: NxtSchema::Types::Params), 44 | &block 45 | ) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/nxt_schema/error.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | class Error < StandardError 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/nxt_schema/errors/coercion_error.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Errors 3 | class CoercionError < Error 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/nxt_schema/errors/invalid.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Errors 3 | class Invalid < NxtSchema::Error 4 | def initialize(node) 5 | @node = node 6 | super(build_message) 7 | end 8 | 9 | attr_reader :node 10 | 11 | def build_message 12 | node.errors 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/nxt_schema/errors/invalid_options.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Errors 3 | class InvalidOptions < NxtSchema::Error 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/nxt_schema/node.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Node 3 | 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/nxt_schema/node/any_of.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Node 3 | class AnyOf < Node::Base 4 | def valid? 5 | valid_node.present? 6 | end 7 | 8 | def call 9 | child_nodes.map(&:call) 10 | 11 | if valid? 12 | self.output = valid_node.output 13 | else 14 | child_nodes.each do |node| 15 | merge_errors(node) 16 | end 17 | end 18 | 19 | self 20 | end 21 | 22 | private 23 | 24 | delegate :[], to: :child_nodes 25 | 26 | def valid_node 27 | child_nodes.find(&:valid?) 28 | end 29 | 30 | def child_nodes 31 | @child_nodes ||= nodes.map { |node| node.build_node(input: input, context: context, parent: self) } 32 | end 33 | 34 | def nodes 35 | @nodes ||= node.sub_nodes.values 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/nxt_schema/node/base.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Node 3 | class Base 4 | def initialize(node:, input: Undefined.new, parent:, context:, error_key:) 5 | @node = node 6 | @input = input 7 | @parent = parent 8 | @output = nil 9 | @error_key = error_key 10 | @context = context || parent&.context 11 | @coerced = false 12 | @coerced_nodes = parent&.coerced_nodes || [] 13 | @is_root = parent.nil? 14 | @root = parent.nil? ? self : parent.root 15 | @errors = ErrorStore.new(self) 16 | @locale = node.options.fetch(:locale) { parent&.locale || 'en' }.to_s 17 | 18 | @index = error_key 19 | resolve_error_key(error_key) 20 | end 21 | 22 | attr_accessor :output, :node, :input 23 | attr_reader :parent, :context, :error_key, :coerced, :coerced_nodes, :root, :errors, :locale, :index 24 | 25 | def call 26 | raise NotImplementedError, 'Implement this in our sub class' 27 | end 28 | 29 | delegate :name, :options, to: :node 30 | 31 | def root? 32 | @is_root 33 | end 34 | 35 | def valid? 36 | errors.empty? 37 | end 38 | 39 | def add_error(error) 40 | errors.add_validation_error(message: error) 41 | end 42 | 43 | def add_schema_error(error) 44 | errors.add_schema_error(message: error) 45 | end 46 | 47 | def merge_errors(node) 48 | errors.merge_errors(node) 49 | end 50 | 51 | def run_validations 52 | return false unless coerced? 53 | 54 | node.validations.each do |validation| 55 | args = [self, input] 56 | validation.call(*args.take(validation.arity)) 57 | end 58 | end 59 | 60 | def up(levels = 1) 61 | 0.upto(levels - 1).inject(self) do |acc, _| 62 | parent = acc.send(:parent) 63 | break acc unless parent 64 | 65 | parent 66 | end 67 | end 68 | 69 | private 70 | 71 | attr_writer :coerced, :root 72 | 73 | def coerce_input 74 | output = input.is_a?(Undefined) && node.omnipresent? ? input : node.type.call(input) 75 | self.output = output 76 | 77 | rescue Dry::Types::CoercionError, NxtSchema::Errors::CoercionError => error 78 | add_schema_error(error.message) 79 | end 80 | 81 | def apply_on_evaluators 82 | node.on_evaluators.each { |evaluator| evaluator.call(input, self, context) { |result| self.input = result } } 83 | end 84 | 85 | def maybe_evaluator_applies? 86 | @maybe_evaluator_applies ||= node.maybe_evaluators.inject(false) do |acc, evaluator| 87 | result = (acc || evaluator.call(input, self, context)) 88 | 89 | if result 90 | self.output = input 91 | break true 92 | else 93 | false 94 | end 95 | end 96 | end 97 | 98 | def register_as_coerced_when_no_errors 99 | return unless valid? 100 | 101 | self.coerced = true 102 | coerced_nodes << self 103 | end 104 | 105 | def resolve_error_key(key) 106 | parts = [parent&.error_key].compact 107 | parts << (key.present? ? "#{node.name}[#{key}]" : node.name) 108 | @error_key = parts.join('.') 109 | end 110 | 111 | def coerced?(&block) 112 | block.call(self) if @coerced && block_given? 113 | @coerced 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/nxt_schema/node/collection.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Node 3 | class Collection < Node::Base 4 | def call 5 | apply_on_evaluators 6 | child_nodes # build nodes here so we can access them even when invalid 7 | return self if maybe_evaluator_applies? 8 | 9 | coerce_input 10 | validate_filled 11 | return self unless valid? 12 | 13 | child_nodes.each_with_index do |item, index| 14 | child_node = item.call 15 | 16 | if !child_node.valid? 17 | merge_errors(child_node) 18 | else 19 | output[index] = child_node.output 20 | end 21 | end 22 | 23 | register_as_coerced_when_no_errors 24 | run_validations 25 | 26 | self 27 | end 28 | 29 | delegate :[], to: :child_nodes 30 | 31 | private 32 | 33 | def validate_filled 34 | add_schema_error('is not allowed to be empty') if input.blank? && !maybe_evaluator_applies? 35 | end 36 | 37 | def child_nodes 38 | @child_nodes ||= begin 39 | return [] unless input.respond_to?(:each_with_index) 40 | 41 | input.each_with_index.map do |item, index| 42 | build_child_node(item, index) 43 | end 44 | end 45 | 46 | end 47 | 48 | def build_child_node(item, error_key) 49 | sub_node.build_node(input: item, context: context, parent: self, error_key: error_key) 50 | end 51 | 52 | def sub_node 53 | @sub_node ||= node.sub_nodes.values.first 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/nxt_schema/node/error_store.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Node 3 | class ErrorStore < ::Hash 4 | def initialize(node) 5 | super() 6 | @node = node 7 | end 8 | 9 | attr_reader :node 10 | 11 | def add_schema_error(message:) 12 | add_error( 13 | node, 14 | NxtSchema::Node::Errors::SchemaError.new( 15 | node: node, 16 | message: message 17 | ) 18 | ) 19 | end 20 | 21 | def add_validation_error(message:) 22 | add_error( 23 | node, 24 | NxtSchema::Node::Errors::ValidationError.new( 25 | node: node, 26 | message: message 27 | ) 28 | ) 29 | end 30 | 31 | def merge_errors(node) 32 | merge!(node.errors) 33 | end 34 | 35 | def add_error(node, error) 36 | self[node.error_key] ||= [] 37 | self[node.error_key] << error 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/nxt_schema/node/errors/schema_error.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Node 3 | module Errors 4 | class SchemaError < ::String 5 | def initialize(node:, message:) 6 | super(message) 7 | @node = node 8 | end 9 | 10 | attr_reader :node 11 | end 12 | end 13 | end 14 | end 15 | 16 | -------------------------------------------------------------------------------- /lib/nxt_schema/node/errors/validation_error.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Node 3 | module Errors 4 | class ValidationError < ::String 5 | def initialize(node:, message:) 6 | super(message) 7 | @node = node 8 | end 9 | 10 | attr_reader :node 11 | end 12 | end 13 | end 14 | end 15 | 16 | -------------------------------------------------------------------------------- /lib/nxt_schema/node/leaf.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Node 3 | class Leaf < Node::Base 4 | def call 5 | apply_on_evaluators 6 | return self if maybe_evaluator_applies? 7 | 8 | coerce_input 9 | register_as_coerced_when_no_errors 10 | run_validations 11 | self 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/nxt_schema/node/schema.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Node 3 | class Schema < Node::Base 4 | def call 5 | apply_on_evaluators 6 | child_nodes # build nodes here so we can access them even when invalid 7 | return self if maybe_evaluator_applies? 8 | 9 | coerce_input 10 | return self unless valid? 11 | 12 | flag_missing_keys 13 | apply_additional_keys_strategy 14 | 15 | child_nodes.each do |key, child| 16 | current_node = child.call 17 | 18 | if !current_node.valid? 19 | merge_errors(current_node) 20 | else 21 | output[key] = current_node.output 22 | end 23 | end 24 | 25 | transform_output_keys 26 | register_as_coerced_when_no_errors 27 | run_validations 28 | self 29 | end 30 | 31 | delegate :[], to: :child_nodes 32 | 33 | private 34 | 35 | def transform_output_keys 36 | transformer = node.output_keys_transformer 37 | return unless transformer && output.respond_to?(:transform_keys!) 38 | 39 | output.transform_keys!(&transformer) 40 | end 41 | 42 | def keys 43 | @keys ||= node.sub_nodes.reject { |key, _| optional_and_not_given_key?(key) }.keys 44 | end 45 | 46 | def additional_keys 47 | @additional_keys ||= input.keys - keys 48 | end 49 | 50 | def optional_and_not_given_key?(key) 51 | node.sub_nodes[key].optional? && !input.key?(key) 52 | end 53 | 54 | def additional_keys? 55 | additional_keys.any? 56 | end 57 | 58 | def missing_keys 59 | @missing_keys ||= node.sub_nodes.reject { |_, node| node.omnipresent? || node.optional? }.keys - input.keys 60 | end 61 | 62 | def apply_additional_keys_strategy 63 | return if allow_additional_keys? 64 | return unless additional_keys? 65 | 66 | if restrict_additional_keys? 67 | add_schema_error("Additional keys are not allowed: #{additional_keys}") 68 | elsif reject_additional_keys? 69 | self.output = output.except(*additional_keys) 70 | end 71 | end 72 | 73 | def flag_missing_keys 74 | return if missing_keys.empty? 75 | 76 | add_schema_error("The following keys are missing: #{missing_keys}") 77 | end 78 | 79 | def allow_additional_keys? 80 | node.additional_keys_strategy == :allow 81 | end 82 | 83 | def reject_additional_keys? 84 | node.additional_keys_strategy == :reject 85 | end 86 | 87 | def restrict_additional_keys? 88 | node.additional_keys_strategy == :restrict 89 | end 90 | 91 | def child_nodes 92 | @child_nodes ||= begin 93 | keys.inject({}) do |acc, key| 94 | child_node = build_child_node(key) 95 | acc[key] = child_node if child_node.present? 96 | acc 97 | end 98 | end 99 | end 100 | 101 | def build_child_node(key) 102 | sub_node = node.sub_nodes[key] 103 | return unless sub_node.present? 104 | 105 | value = input_has_key?(input, key) ? input[key] : Undefined.new 106 | sub_node.build_node(input: value, context: context, parent: self) 107 | end 108 | 109 | def input_has_key?(input, key) 110 | input.respond_to?(:key?) && input.key?(key) 111 | end 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/nxt_schema/registry.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Registry 3 | module ClassMethods 4 | def schemas 5 | @schemas ||= NxtSchema::Registry::Proxy.new(self) 6 | end 7 | 8 | def inherited(subclass) 9 | schemas.each do |key, schema| 10 | subclass.schemas.register(key, schema) 11 | end 12 | 13 | super 14 | end 15 | end 16 | 17 | def self.included(base) 18 | base.extend(ClassMethods) 19 | 20 | delegate :schemas, to: :class 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/nxt_schema/registry/proxy.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Registry 3 | class Proxy 4 | def initialize(namespace) 5 | @registry = ::NxtRegistry::Registry.new(namespace, call: false) 6 | end 7 | 8 | attr_reader :registry 9 | 10 | delegate_missing_to :registry 11 | 12 | def apply(key, input) 13 | resolve!(key).apply(input: input) 14 | end 15 | 16 | def apply!(key, input) 17 | resolve!(key).apply!(input: input) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/nxt_schema/template/any_of.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Template 3 | class AnyOf < Base 4 | include HasSubNodes 5 | 6 | def initialize(name:, type: nil, parent_node:, **options, &block) 7 | super 8 | ensure_sub_nodes_present 9 | end 10 | 11 | def collection(name = sub_nodes.count, type = NxtSchema::Template::Collection::DEFAULT_TYPE, **options, &block) 12 | super 13 | end 14 | 15 | def schema(name = sub_nodes.count, type = NxtSchema::Template::Schema::DEFAULT_TYPE, **options, &block) 16 | super 17 | end 18 | 19 | def node(name = sub_nodes.count, node_or_type_of_node = nil, **options, &block) 20 | super 21 | end 22 | 23 | def on(*args) 24 | raise NotImplementedError 25 | end 26 | 27 | def maybe(*args) 28 | raise NotImplementedError 29 | end 30 | 31 | private 32 | 33 | def resolve_type(name_or_type) 34 | nil 35 | end 36 | 37 | def resolve_optional_option 38 | return unless options.key?(:optional) 39 | 40 | raise InvalidOptions, "The optional option is not available for nodes of type #{self.class.name}" 41 | end 42 | 43 | def resolve_omnipresent_option 44 | return unless options.key?(:omnipresent) 45 | 46 | raise InvalidOptions, "The omnipresent option is not available for nodes of type #{self.class.name}" 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/nxt_schema/template/base.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Template 3 | class Base 4 | def initialize(name:, type:, parent_node:, **options, &block) 5 | resolve_name(name) 6 | 7 | @parent_node = parent_node 8 | @options = options 9 | @is_root_node = parent_node.nil? 10 | @root_node = parent_node.nil? ? self : parent_node.root_node 11 | @path = resolve_path 12 | @on_evaluators = [] 13 | @maybe_evaluators = [] 14 | @validations = [] 15 | @configuration = block 16 | 17 | resolve_input_preprocessor 18 | resolve_output_keys_transformer 19 | resolve_context 20 | resolve_optional_option 21 | resolve_omnipresent_option 22 | resolve_type_system 23 | resolve_type(type) 24 | resolve_additional_keys_strategy 25 | node_class # memoize 26 | configure(&block) if block_given? 27 | end 28 | 29 | attr_accessor :name, 30 | :parent_node, 31 | :options, 32 | :type, 33 | :root_node, 34 | :additional_keys_strategy 35 | 36 | attr_reader :type_system, 37 | :path, 38 | :context, 39 | :meta, 40 | :on_evaluators, 41 | :maybe_evaluators, 42 | :validations, 43 | :configuration, 44 | :output_keys_transformer, 45 | :input_preprocessor 46 | 47 | def apply(input: Undefined.new, context: self.context, parent: nil, error_key: nil) 48 | build_node(input: input, context: context, parent: parent, error_key: error_key).call 49 | end 50 | 51 | def apply!(input: Undefined.new, context: self.context, parent: nil, error_key: nil) 52 | result = build_node(input: input, context: context, parent: parent, error_key: error_key).call 53 | return result if parent 54 | 55 | raise NxtSchema::Errors::Invalid.new(result) if result.errors.any? 56 | 57 | result.output 58 | end 59 | 60 | def build_node(input: Undefined.new, context: self.context, parent: nil, error_key: nil) 61 | node_class.new( 62 | node: self, 63 | input: preprocess_input(input), 64 | parent: parent, 65 | context: context, 66 | error_key: error_key 67 | ) 68 | end 69 | 70 | def root_node? 71 | @is_root_node 72 | end 73 | 74 | def optional? 75 | @optional 76 | end 77 | 78 | def omnipresent? 79 | @omnipresent 80 | end 81 | 82 | def default(value = NxtSchema::Undefined.new, &block) 83 | value = missing_input?(value) ? block : value 84 | condition = ->(input) { missing_input?(input) || input.nil? } 85 | on(condition, value) 86 | 87 | self 88 | end 89 | 90 | def on(condition, value = NxtSchema::Undefined.new, &block) 91 | value = missing_input?(value) ? block : value 92 | on_evaluators << OnEvaluator.new(condition: condition, value: value) 93 | 94 | self 95 | end 96 | 97 | def maybe(value = NxtSchema::Undefined.new, &block) 98 | value = missing_input?(value) ? block : value 99 | maybe_evaluators << MaybeEvaluator.new(value: value) 100 | 101 | self 102 | end 103 | 104 | def validate(key = NxtSchema::Undefined.new, *args, &block) 105 | # TODO: This does not really work with all kinds of chaining combinations yet! 106 | 107 | validator = if key.is_a?(Symbol) 108 | validator(key, *args) 109 | elsif key.respond_to?(:call) 110 | key 111 | elsif block_given? 112 | if key.is_a?(NxtSchema::Undefined) 113 | block 114 | else 115 | configure(&block) 116 | end 117 | else 118 | raise ArgumentError, "Don't know how to resolve validator from: #{key} with: #{args} #{block}" 119 | end 120 | 121 | register_validator(validator) 122 | 123 | self 124 | end 125 | 126 | def validate_with(&block) 127 | proxy = ->(node) { NxtSchema::Validator::ValidateWithProxy.new(node).validate(&block) } 128 | register_validator(proxy) 129 | end 130 | 131 | private 132 | 133 | attr_writer :path, :meta, :context, :on_evaluators, :maybe_evaluators 134 | 135 | def validator(key, *args) 136 | Validators::REGISTRY.resolve!(key).new(*args).build 137 | end 138 | 139 | def register_validator(validator) 140 | validations << validator 141 | end 142 | 143 | def resolve_type(name_or_type) 144 | @type = root_node.send(:type_resolver).resolve(type_system, name_or_type) 145 | end 146 | 147 | def resolve_type_system 148 | @type_system = TypeSystemResolver.new(node: self).call 149 | end 150 | 151 | def type_resolver 152 | @type_resolver ||= begin 153 | root_node? ? TypeResolver.new : (raise NoMethodError, 'type_resolver is only available on root node') 154 | end 155 | end 156 | 157 | def node_class 158 | @node_class ||= "NxtSchema::Node::#{self.class.name.demodulize}".constantize 159 | end 160 | 161 | def configure(&block) 162 | if block.arity == 1 163 | block.call(self) 164 | else 165 | instance_exec(&block) 166 | end 167 | end 168 | 169 | def resolve_additional_keys_strategy 170 | @additional_keys_strategy = options.fetch(:additional_keys) do 171 | parent_node&.send(:additional_keys_strategy) || :allow 172 | end 173 | end 174 | 175 | def resolve_optional_option 176 | optional = options.fetch(:optional, false) 177 | raise Errors::InvalidOptions, 'Optional nodes are only available within schemas' if optional && !parent_node.is_a?(Schema) 178 | raise Errors::InvalidOptions, "Can't make omnipresent node optional" if optional && omnipresent? 179 | 180 | if optional.respond_to?(:call) 181 | # When a node is conditionally optional we make it optional and add a validator to the parent to check 182 | # that it's there when the option does not apply. 183 | optional_node_validator = validator(:optional_node, optional, name) 184 | parent_node.send(:register_validator, optional_node_validator) 185 | @optional = true 186 | else 187 | @optional = optional 188 | end 189 | end 190 | 191 | def resolve_omnipresent_option 192 | omnipresent = options.fetch(:omnipresent, false) 193 | raise Errors::InvalidOptions, 'Omnipresent nodes are only available within schemas' if omnipresent && !parent_node.is_a?(Schema) 194 | raise Errors::InvalidOptions, "Can't make omnipresent node optional" if optional? && omnipresent 195 | 196 | @omnipresent = omnipresent 197 | end 198 | 199 | def resolve_path 200 | self.path = root_node? ? name : "#{parent_node.path}.#{name}" 201 | end 202 | 203 | def resolve_context 204 | self.context = options.fetch(:context) { parent_node&.send(:context) } 205 | end 206 | 207 | def missing_input?(value) 208 | value.is_a? Undefined 209 | end 210 | 211 | def resolve_input_preprocessor 212 | @input_preprocessor ||= begin 213 | if root_node? 214 | options.key?(:preprocess_input) ? options.fetch(:preprocess_input) : default_input_preprocessor 215 | else 216 | options.key?(:preprocess_input) ? options.fetch(:preprocess_input) : parent_node&.input_preprocessor 217 | end 218 | end 219 | end 220 | 221 | def resolve_output_keys_transformer 222 | @output_keys_transformer = options.fetch(:transform_output_keys) { parent_node&.output_keys_transformer || ->(key) { key.to_sym } } 223 | end 224 | 225 | def resolve_name(name) 226 | raise ArgumentError, 'Name can either be a symbol or an integer' unless name.class.in?([Symbol, Integer]) 227 | 228 | @name = name 229 | end 230 | 231 | def preprocess_input(input) 232 | return input unless input_preprocessor.present? 233 | input_preprocessor.call(input, self) 234 | end 235 | 236 | def default_input_preprocessor 237 | ->(input, node) do 238 | return input unless node.is_a?(NxtSchema::Template::Schema) && input.respond_to?(:transform_keys) 239 | input.transform_keys(&:to_sym) 240 | end 241 | end 242 | end 243 | end 244 | end 245 | -------------------------------------------------------------------------------- /lib/nxt_schema/template/collection.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Template 3 | class Collection < Template::Base 4 | include HasSubNodes 5 | 6 | DEFAULT_TYPE = NxtSchema::Types::Strict::Array 7 | 8 | def initialize(name:, type: DEFAULT_TYPE, parent_node:, **options, &block) 9 | super 10 | ensure_sub_nodes_present 11 | end 12 | 13 | private 14 | 15 | def add_sub_node(node) 16 | # TODO: Spec that this raises 17 | raise ArgumentError, "It's not possible to define multiple nodes within a collection" unless sub_nodes.empty? 18 | 19 | super 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/nxt_schema/template/has_sub_nodes.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Template 3 | module HasSubNodes 4 | def collection(name, type = NxtSchema::Template::Collection::DEFAULT_TYPE, **options, &block) 5 | node = NxtSchema::Template::Collection.new( 6 | name: name, 7 | type: type, 8 | parent_node: self, 9 | **options, 10 | &block 11 | ) 12 | 13 | add_sub_node(node) 14 | end 15 | 16 | alias nodes collection 17 | 18 | def schema(name, type = NxtSchema::Template::Schema::DEFAULT_TYPE, **options, &block) 19 | node = NxtSchema::Template::Schema.new( 20 | name: name, 21 | type: type, 22 | parent_node: self, 23 | **options, 24 | &block 25 | ) 26 | 27 | add_sub_node(node) 28 | end 29 | 30 | def any_of(name, **options, &block) 31 | node = NxtSchema::Template::AnyOf.new( 32 | name: name, 33 | parent_node: self, 34 | **options, 35 | &block 36 | ) 37 | 38 | add_sub_node(node) 39 | end 40 | 41 | def node(name, node_or_type_of_node, **options, &block) 42 | node = if node_or_type_of_node.is_a?(NxtSchema::Template::Base) 43 | raise ArgumentError, "Can't provide a block along with a node" if block.present? 44 | 45 | node_or_type_of_node.class.new( 46 | name: name, 47 | type: node_or_type_of_node.type, 48 | parent_node: self, 49 | **node_or_type_of_node.options.merge(options), 50 | &node_or_type_of_node.configuration 51 | ) 52 | else 53 | NxtSchema::Template::Leaf.new( 54 | name: name, 55 | type: node_or_type_of_node, 56 | parent_node: self, 57 | **options, 58 | &block 59 | ) 60 | end 61 | 62 | add_sub_node(node) 63 | end 64 | 65 | alias required node 66 | 67 | def add_sub_node(node) 68 | sub_nodes.add(node) 69 | node 70 | end 71 | 72 | def sub_nodes 73 | @sub_nodes ||= Template::SubNodes.new 74 | end 75 | 76 | def [](key) 77 | sub_nodes[key] 78 | end 79 | 80 | def ensure_sub_nodes_present 81 | return if sub_nodes.any? 82 | 83 | raise NxtSchema::Errors::InvalidOptions, "#{self.class.name} must have sub nodes" 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/nxt_schema/template/leaf.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Template 3 | class Leaf < Template::Base 4 | def initialize(name:, type: :String, parent_node:, **options, &block) 5 | super 6 | end 7 | 8 | def leaf? 9 | true 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/nxt_schema/template/maybe_evaluator.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Template 3 | class MaybeEvaluator 4 | def initialize(value:) 5 | @value = value 6 | end 7 | 8 | def call(target = nil, *args) 9 | evaluator = evaluator(target, *args) 10 | 11 | if evaluator.value? 12 | # When a value was given we check if this equals to the input 13 | evaluator.call == target 14 | else 15 | evaluator.call 16 | end 17 | end 18 | 19 | private 20 | 21 | def evaluator(target, *args) 22 | Callable.new(value, target, *args) 23 | end 24 | 25 | attr_reader :value 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/nxt_schema/template/on_evaluator.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Template 3 | class OnEvaluator 4 | def initialize(condition:, value:) 5 | @condition = condition 6 | @value = value 7 | end 8 | 9 | def call(target = nil, *args, &block) 10 | return unless condition_applies?(target, *args) 11 | 12 | result = Callable.new(value, target, *args).call 13 | block.yield(result) 14 | end 15 | 16 | private 17 | 18 | def condition_applies?(target, *args) 19 | Callable.new(condition, target, *args).call 20 | end 21 | 22 | attr_reader :condition, :value 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/nxt_schema/template/schema.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Template 3 | class Schema < Template::Base 4 | include HasSubNodes 5 | 6 | DEFAULT_TYPE = NxtSchema::Types::Strict::Hash 7 | 8 | def initialize(name:, type: DEFAULT_TYPE, parent_node:, **options, &block) 9 | super 10 | ensure_sub_nodes_present 11 | end 12 | 13 | def optional(name, node_or_type_of_node, **options, &block) 14 | node(name, node_or_type_of_node, **options.merge(optional: true), &block) 15 | end 16 | 17 | def omnipresent(name, node_or_type_of_node, **options, &block) 18 | node(name, node_or_type_of_node, **options.merge(omnipresent: true), &block) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/nxt_schema/template/sub_nodes.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Template 3 | class SubNodes < ::Hash 4 | def initialize 5 | super 6 | transform_keys { |k| k.to_sym } 7 | end 8 | 9 | def add(node) 10 | node_name = node.name 11 | ensure_node_name_free(node_name) 12 | self[node_name] = node 13 | end 14 | 15 | def ensure_node_name_free(name) 16 | return unless key?(name) 17 | 18 | raise KeyError, "Node with name '#{name}' already exists! Node names must be unique!" 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/nxt_schema/template/type_resolver.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Template 3 | class TypeResolver 4 | def resolve(type_system, type) 5 | @resolve ||= {} 6 | @resolve[type] ||= begin 7 | if type.is_a?(Symbol) 8 | resolve_type_from_symbol(type, type_system) 9 | elsif type.respond_to?(:call) 10 | type 11 | else 12 | raise_type_not_resolvable_error(type) 13 | end 14 | rescue NxtRegistry::Errors::KeyNotRegisteredError => error 15 | raise_type_not_resolvable_error(type) 16 | end 17 | end 18 | 19 | private 20 | 21 | def resolve_type_from_symbol(type, type_system) 22 | classified_type = type.to_s.classify 23 | 24 | return type_system.const_get(classified_type) if type_defined_in_type_system?(type, type_system) 25 | return NxtSchema::Types::Nominal.const_get(classified_type) if type_defined_in_type_system?(type, NxtSchema::Types::Nominal) 26 | 27 | NxtSchema::Types.registry(:types).resolve!(type) 28 | end 29 | 30 | def type_defined_in_type_system?(type, type_system) 31 | type_system.constants.include?(type) 32 | end 33 | 34 | def raise_type_not_resolvable_error(type) 35 | raise ArgumentError, "Can't resolve type: #{type}" 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/nxt_schema/template/type_system_resolver.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Template 3 | class TypeSystemResolver 4 | include NxtInit 5 | attr_init :node 6 | 7 | delegate_missing_to :node 8 | 9 | def call 10 | type_system = options.fetch(:type_system) { parent_node&.type_system } 11 | 12 | if type_system.is_a?(Module) 13 | type_system 14 | elsif type_system.is_a?(Symbol) || type_system.is_a?(String) 15 | "NxtSchema::Types::#{type_system.to_s.classify}".constantize 16 | else 17 | NxtSchema::Types 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/nxt_schema/types.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Types 3 | include Dry.Types() 4 | extend NxtRegistry 5 | 6 | registry(:types, call: false) do 7 | register(:StrippedString, Strict::String.constructor(->(string) { string&.strip })) 8 | register(:LengthyStrippedString, resolve!(:StrippedString).constrained(min_size: 1)) 9 | register(:Enum, -> (*values) { Strict::String.enum(*values) }) 10 | register(:SymbolizedEnum, -> (*values) { Coercible::Symbol.enum(*values) }) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/nxt_schema/undefined.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | class Undefined 3 | def inspect 4 | self.class.name 5 | end 6 | 7 | alias to_s inspect 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/nxt_schema/validators/attribute.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Validators 3 | class Attribute < Validator 4 | def initialize(method, expectation) 5 | @method = method 6 | @expectation = expectation 7 | end 8 | 9 | register_as :attribute, :attr 10 | attr_reader :method, :expectation 11 | 12 | # Query any attribute on a value with validator(:attribute, :size, ->(s) { s < 7 }) 13 | 14 | def build 15 | lambda do |node, value| 16 | raise ArgumentError, "#{value} does not respond to query: #{method}" unless value.respond_to?(method) 17 | 18 | if expectation.call(value.send(method)) 19 | true 20 | else 21 | node.add_error( 22 | translate_error( 23 | node.locale, 24 | attribute: value, 25 | attribute_name: method, 26 | value: value.send(method) 27 | ) 28 | ) 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/nxt_schema/validators/equal_to.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Validators 3 | class Equality < Validator 4 | def initialize(expectation) 5 | @expectation = expectation 6 | end 7 | 8 | register_as :equal_to, :eql 9 | attr_reader :expectation 10 | 11 | # Query for equality validator(:equality, 3) 12 | # Query for equality validator(:eql, -> { 3 * 3 * 60 }) 13 | 14 | def build 15 | lambda do |node, value| 16 | expected_value = Callable.new(expectation, nil, value).call 17 | 18 | if value == expected_value 19 | true 20 | else 21 | node.add_error( 22 | translate_error( 23 | node.locale, 24 | actual: value, 25 | expected: expected_value 26 | ) 27 | ) 28 | end 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/nxt_schema/validators/error_messages.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Validators 3 | class ErrorMessages 4 | class << self 5 | def values 6 | @values ||= {} 7 | end 8 | 9 | def values=(value) 10 | @values = value 11 | end 12 | 13 | def load(paths = files) 14 | Array(paths).each do |path| 15 | new_values = YAML.load(ERB.new(File.read(path)).result).with_indifferent_access 16 | self.values = values.deep_merge!(new_values) 17 | end 18 | end 19 | 20 | def resolve(locale, key, **options) 21 | message = begin 22 | values.fetch(locale).fetch(key) 23 | rescue KeyError 24 | raise "Could not resolve error message for #{locale}->#{key}" 25 | end 26 | 27 | message % options 28 | end 29 | 30 | def files 31 | @files ||= begin 32 | files = Dir.entries(File.expand_path('../error_messages/', __FILE__)).map do |filename| 33 | File.expand_path("../error_messages/#{filename}", __FILE__) 34 | end 35 | 36 | files.select { |f| !File.directory? f } 37 | end 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/nxt_schema/validators/error_messages/en.yaml: -------------------------------------------------------------------------------- 1 | en: 2 | required_key_missing: "Required key :%{key} is missing" 3 | additional_keys_detected: "Additional keys %{keys} not allowed" 4 | attribute: "%{attribute} has invalid %{attribute_name} attribute of %{value}" 5 | equal_to: "%{actual} does not equal %{expected}" 6 | excludes: "%{value} cannot contain %{target}" 7 | includes: "%{value} must include %{target}" 8 | excluded_in: "%{value} must be excluded in %{target}" 9 | included_in: "%{value} must be included in %{target}" 10 | greater_than: "%{value} must be greater than %{threshold}" 11 | greater_than_or_equal: "%{value} must be greater than or equal to %{threshold}" 12 | less_than: "%{value} must be less than %{threshold}" 13 | less_than_or_equal: "%{value} must be less than or equal to %{threshold}" 14 | pattern: "%{value} must match pattern %{pattern}" 15 | query: "%{value}.%{query} was %{actual} and must be true" 16 | emptiness: "%{value} cannot not be empty" 17 | -------------------------------------------------------------------------------- /lib/nxt_schema/validators/excluded_in.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Validators 3 | class Excluded < Validator 4 | def initialize(target) 5 | @target = target 6 | end 7 | 8 | register_as :excluded_in 9 | attr_reader :target 10 | 11 | def build 12 | lambda do |node, value| 13 | if target.exclude?(value) 14 | true 15 | else 16 | message = translate_error(node.locale, target: target, value: value) 17 | node.add_error(message) 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/nxt_schema/validators/excludes.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Validators 3 | class Excludes < Validator 4 | def initialize(target) 5 | @target = target 6 | end 7 | 8 | register_as :excludes 9 | attr_reader :target 10 | 11 | def build 12 | lambda do |node, value| 13 | if value.exclude?(target) 14 | true 15 | else 16 | message = translate_error(node.locale, target: target, value: value) 17 | node.add_error(message) 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/nxt_schema/validators/greater_than.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Validators 3 | class GreaterThan < Validator 4 | def initialize(threshold) 5 | @threshold = threshold 6 | end 7 | 8 | register_as :greater_than 9 | attr_reader :threshold 10 | 11 | def build 12 | lambda do |node, value| 13 | if value > threshold 14 | true 15 | else 16 | message = translate_error(node.locale, value: value, threshold: threshold) 17 | node.add_error(message) 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/nxt_schema/validators/greater_than_or_equal.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Validators 3 | class GreaterThanOrEqual < Validator 4 | def initialize(threshold) 5 | @threshold = threshold 6 | end 7 | 8 | register_as :greater_than_or_equal, :gt_or_eql 9 | attr_reader :threshold 10 | 11 | def build 12 | lambda do |node, value| 13 | if value >= threshold 14 | true 15 | else 16 | message = translate_error(node.locale, value: value, threshold: threshold) 17 | node.add_error(message) 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/nxt_schema/validators/included_in.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Validators 3 | class Included < Validator 4 | def initialize(target) 5 | @target = target 6 | end 7 | 8 | register_as :included_in 9 | attr_reader :target 10 | 11 | def build 12 | lambda do |node, value| 13 | if target.include?(value) 14 | true 15 | else 16 | message = translate_error(node.locale, value: value, target: target) 17 | node.add_error(message) 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/nxt_schema/validators/includes.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Validators 3 | class Includes < Validator 4 | def initialize(target) 5 | @target = target 6 | end 7 | 8 | register_as :includes 9 | attr_reader :target 10 | 11 | def build 12 | lambda do |node, value| 13 | if value.include?(target) 14 | true 15 | else 16 | message = translate_error(coerced?.locale, value: value, target: target) 17 | node.add_error(message) 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/nxt_schema/validators/less_than.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Validators 3 | class LessThan < Validator 4 | def initialize(threshold) 5 | @threshold = threshold 6 | end 7 | 8 | register_as :less_than 9 | attr_reader :threshold 10 | 11 | def build 12 | lambda do |node, value| 13 | if value < threshold 14 | true 15 | else 16 | message = translate_error(node.locale, value: value, threshold: threshold) 17 | node.add_error(message) 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/nxt_schema/validators/less_than_or_equal.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Validators 3 | class LessThanOrEqual < Validator 4 | def initialize(threshold) 5 | @threshold = threshold 6 | end 7 | 8 | register_as :less_than_or_equal, :lt_or_eql 9 | attr_reader :threshold 10 | 11 | def build 12 | lambda do |node, value| 13 | if value <= threshold 14 | true 15 | else 16 | message = translate_error(node.locale, value: value, threshold: threshold) 17 | node.add_error(message) 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/nxt_schema/validators/optional_node.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Validators 3 | class OptionalNode < Validator 4 | def initialize(conditional, missing_key) 5 | @conditional = conditional 6 | @missing_key = missing_key 7 | end 8 | 9 | register_as :optional_node 10 | attr_reader :conditional, :missing_key 11 | 12 | def build 13 | lambda do |node, value| 14 | args = [node, value] 15 | 16 | return if conditional.call(*args.take(conditional.arity)) 17 | return if node.send(:keys).include?(missing_key.to_sym) 18 | 19 | message = ErrorMessages.resolve( 20 | node.locale, 21 | :required_key_missing, 22 | key: missing_key, 23 | target: node.input 24 | ) 25 | 26 | node.add_error(message) 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/nxt_schema/validators/pattern.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Validators 3 | class Pattern < Validator 4 | def initialize(pattern) 5 | @pattern = pattern 6 | end 7 | 8 | register_as :pattern, :format 9 | attr_reader :pattern 10 | 11 | def build 12 | lambda do |node, value| 13 | if value.match(pattern) 14 | true 15 | else 16 | message = translate_error(node.locale, value: value, pattern: pattern) 17 | node.add_error(message) 18 | false 19 | end 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/nxt_schema/validators/query.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Validators 3 | class Query < Validator 4 | def initialize(method) 5 | @method = method 6 | end 7 | 8 | register_as :query 9 | attr_reader :method 10 | 11 | # Query a boolean method on you value => node(:test, :String).validate(:query, :good_enough?) 12 | # => Would be valid if value.good_enough? is truthy 13 | 14 | def build 15 | lambda do |node, value| 16 | raise ArgumentError, "#{value} does not respond to query: #{method}" unless value.respond_to?(method) 17 | 18 | if value.send(method) 19 | true 20 | else 21 | message = translate_error(node.locale, value: value, actual: value.send(method), query: method) 22 | node.add_error(message) 23 | end 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/nxt_schema/validators/registry.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Validators 3 | REGISTRY = NxtRegistry::Registry.new(call: false) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/nxt_schema/validators/validate_with_proxy.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Validator 3 | class ValidateWithProxy 4 | def initialize(node) 5 | @node = node 6 | @aggregated_errors = [] 7 | end 8 | 9 | attr_reader :node 10 | 11 | delegate_missing_to :node 12 | 13 | def validate(&block) 14 | result = instance_exec(&block) 15 | return if result 16 | 17 | copy_aggregated_errors_to_node 18 | end 19 | 20 | def add_error(error) 21 | aggregated_errors << error 22 | false 23 | end 24 | 25 | def copy_aggregated_errors_to_node 26 | aggregated_errors.each do |error| 27 | node.add_error(error) 28 | end 29 | end 30 | 31 | private 32 | 33 | attr_reader :aggregated_errors 34 | 35 | def validator(key, *args) 36 | validator = node.node.send(:validator, key, *args) 37 | validator.call(self, node.input) 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/nxt_schema/validators/validator.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | module Validators 3 | class Validator 4 | def self.register_as(*keys) 5 | keys.each do |key| 6 | NxtSchema::Validators::REGISTRY.register(key, self) 7 | end 8 | 9 | define_method('key') { @key ||= keys.first } 10 | end 11 | 12 | def translate_error(locale, **options) 13 | NxtSchema::Validators::ErrorMessages.resolve(locale, key, **options) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/nxt_schema/version.rb: -------------------------------------------------------------------------------- 1 | module NxtSchema 2 | VERSION = "1.0.2" 3 | end 4 | -------------------------------------------------------------------------------- /nxt_schema.gemspec: -------------------------------------------------------------------------------- 1 | 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "nxt_schema/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "nxt_schema" 8 | spec.version = NxtSchema::VERSION 9 | spec.authors = ["Andreas Robecke"] 10 | spec.email = ["a.robecke@getsafe.de"] 11 | 12 | spec.summary = %q{Write a short summary, because RubyGems requires one.} 13 | spec.description = %q{Write a longer description or delete this line.} 14 | spec.homepage = "https://www.getsafe.de" 15 | spec.license = "MIT" 16 | 17 | # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host' 18 | # to allow pushing to a single host or delete this section to allow pushing to any host. 19 | if spec.respond_to?(:metadata) 20 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 21 | 22 | spec.metadata["homepage_uri"] = spec.homepage 23 | spec.metadata["source_code_uri"] = "https://www.getsafe.de" 24 | else 25 | raise "RubyGems 2.0 or newer is required to protect against " \ 26 | "public gem pushes." 27 | end 28 | 29 | # Specify which files should be added to the gem when it is released. 30 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 31 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do 32 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 33 | end 34 | spec.bindir = "exe" 35 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 36 | spec.require_paths = ["lib"] 37 | 38 | spec.add_dependency "activesupport" 39 | spec.add_dependency "dry-types" 40 | spec.add_dependency "nxt_registry" 41 | spec.add_dependency "nxt_init" 42 | spec.add_development_dependency "bundler", "~> 1.17" 43 | spec.add_development_dependency "rake", "~> 12.3.3" 44 | spec.add_development_dependency "rspec", "~> 3.0" 45 | spec.add_development_dependency "pry" 46 | spec.add_development_dependency "method_profiler" 47 | end 48 | -------------------------------------------------------------------------------- /spec/dsl_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe NxtSchema::Dsl do 4 | let(:test_class) do 5 | Class.new do 6 | extend NxtSchema::Dsl 7 | 8 | module Helpers 9 | def default_value 10 | -> (_, node) { "There was no default value for #{node.name} at: #{Time.current}" } 11 | end 12 | end 13 | 14 | SCHEMA = schema(:person) do 15 | extend Helpers 16 | 17 | required(:first_name, :String) 18 | required(:last_name, :String).default(default_value) 19 | end 20 | 21 | def call(input) 22 | SCHEMA.apply(input: input) 23 | end 24 | end 25 | end 26 | 27 | subject { test_class.new.call(first_name: 'Andy', last_name: nil) } 28 | 29 | it 'can access the helper methods' do 30 | expect(subject.output).to match( 31 | first_name: "Andy", 32 | last_name: /There was no default value for last_name at.*/ 33 | ) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/merging_schemas_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe NxtSchema do 2 | let(:schema) do 3 | address = address_schema 4 | cyphers = cyphers_schema 5 | 6 | NxtSchema.schema(:person) do 7 | required(:first_name, :String) 8 | required(:last_name, :String) 9 | required(:address, address) 10 | required(:cyphers, cyphers) 11 | end 12 | end 13 | 14 | let(:address_schema) do 15 | cyphers = cyphers_schema 16 | 17 | NxtSchema.schema(:address) do 18 | required(:street, :String) 19 | required(:zip_code, :String) 20 | required(:town, :String).validate(:equal_to, 'Kaiserslautern') 21 | node(:country, :String, optional: ->(node) { node[:town].input == 'Kaiserslautern' }) 22 | required(:cyphers, cyphers) 23 | end 24 | end 25 | 26 | 27 | let(:cyphers_schema) do 28 | NxtSchema.collection do 29 | required(:cypher, :String) 30 | end 31 | end 32 | 33 | let(:input) do 34 | { 35 | first_name: 'Andy', 36 | last_name: 'Superstar', 37 | cyphers: %w[A N D], 38 | address: { 39 | street: 'Am Waeldchen 9', 40 | zip_code: '67661', 41 | town: 'Kaiserslautern', 42 | cyphers: %w[A N D] 43 | } 44 | } 45 | end 46 | 47 | subject do 48 | schema.apply(input: input) 49 | end 50 | 51 | it do 52 | expect(address_schema.parent_node).to be_nil 53 | expect(schema[:address].parent_node).to eq(schema) 54 | expect(schema[:cyphers].parent_node).to eq(schema) 55 | expect(schema[:address][:cyphers].parent_node).to eq(schema[:address]) 56 | 57 | expect(subject).to be_valid 58 | expect(subject.output).to eq(input) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/node/benchmark_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe NxtSchema do 4 | let(:address_schema) do 5 | NxtSchema.schema(:address) do 6 | required(:street, :String) 7 | required(:street_number, :String) 8 | required(:city, :String) 9 | required(:zip_code, :String) 10 | required(:country, :String).validate(:included_in, %w[Germany, France, UK]) 11 | end 12 | end 13 | 14 | let(:schema) do 15 | address = address_schema 16 | 17 | NxtSchema.collection(:people) do 18 | schema(:person) do 19 | required(:first_name, :String) 20 | required(:last_name, :String) 21 | required(:birthdate, :Date) 22 | required(:age, :Integer) 23 | required(:email, :String).validate(:pattern, /\A.*@.*\z/) 24 | required(:language, :String).validate(:included_in, %w[de en fr]) 25 | 26 | required(:address, address) 27 | 28 | schema(:company) do 29 | required(:name, :String) 30 | required(:position, :String) 31 | required(:address, address) 32 | end 33 | end 34 | end 35 | end 36 | 37 | let(:input) do 38 | 0.upto(1000).map do |index| 39 | { 40 | first_name: "Nico##{index}", 41 | last_name: "Stoianov#{index}", 42 | birthdate: Date.today + index.days, 43 | age: 20 + index, 44 | email: "nico#{index}@stoianov.com", 45 | language: %w[de en fr].sample, 46 | address: { 47 | street: "Langer Anger", 48 | street_number: index.to_s, 49 | city: 'Heidelberg', 50 | zip_code: index.to_s, 51 | country: %w[Germany, France, UK].sample 52 | }, 53 | company: { 54 | name: 'Getsafe', 55 | position: 'Boss', 56 | address: { 57 | street: "Langer Anger", 58 | street_number: index.to_s, 59 | city: 'Heidelberg', 60 | zip_code: index.to_s, 61 | country: %w[Germany, France, UK].sample 62 | } 63 | } 64 | } 65 | end 66 | end 67 | 68 | before { input } # memoize 69 | 70 | subject { Benchmark.measure { schema.apply(input: input) }.real } 71 | 72 | it do 73 | expect(subject).to be_between(0.0, 0.3) 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /spec/node/context_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe NxtSchema do 4 | 5 | let(:last_name) { 'Stoianov' } 6 | 7 | context 'when a context is given at apply time' do 8 | subject { schema.apply(input: input, context: apply_context) } 9 | 10 | let(:schema) do 11 | NxtSchema.schema(:developers) do 12 | required(:first_name, :String) 13 | required(:last_name, :String).default do |_, node| 14 | node.context.default_last_name 15 | end 16 | end 17 | end 18 | 19 | let(:apply_context) do 20 | Module.new do 21 | def default_last_name 22 | 'Stoianov' 23 | end 24 | 25 | module_function :default_last_name 26 | end 27 | end 28 | 29 | let(:input) { { first_name: 'Nico', last_name: nil } } 30 | 31 | it { expect(subject).to be_valid } 32 | 33 | it { expect(subject.output).to eq(first_name: 'Nico', last_name: 'Stoianov') } 34 | end 35 | 36 | context 'when a context is given at definition time' do 37 | subject { schema.apply(input: input) } 38 | 39 | let(:schema) do 40 | NxtSchema.schema(:developers, context: build_context) do 41 | required(:first_name, :String) 42 | required(:last_name, :String).validate(context.validate_last_name) 43 | end 44 | end 45 | 46 | let(:build_context) do 47 | Module.new do 48 | def validate_last_name 49 | ->(node) { node.add_error('Invalid last name') unless node.input == 'Stoianov' } 50 | end 51 | 52 | module_function :validate_last_name 53 | end 54 | end 55 | 56 | let(:input) { { first_name: 'Nico', last_name: 'Other' } } 57 | 58 | it { expect(subject).to_not be_valid } 59 | 60 | it { expect(subject.errors).to eq("developers.last_name" => ["Invalid last name"]) } 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/node/maybe_evaluator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe NxtSchema do 4 | subject do 5 | schema.apply(input: input) 6 | end 7 | 8 | context 'with a method' do 9 | let(:schema) do 10 | NxtSchema.schema(:developers) do 11 | required(:first_name, :String) 12 | required(:last_name, :String).maybe(:blank?) 13 | end 14 | end 15 | 16 | context 'when the method applies' do 17 | let(:input) { { first_name: 'Andy', last_name: '' } } 18 | 19 | it { expect(subject).to be_valid } 20 | 21 | it do 22 | expect(subject.output).to eq(first_name: 'Andy', last_name: '') 23 | end 24 | end 25 | 26 | context 'when the method does not apply' do 27 | let(:input) { { first_name: 'Andy' } } 28 | 29 | it { expect(subject).to_not be_valid } 30 | 31 | it do 32 | expect(subject.errors).to eq( 33 | 'developers' => ['The following keys are missing: [:last_name]'], 34 | 'developers.last_name' => ['NxtSchema::Undefined violates constraints (type?(String, NxtSchema::Undefined) failed)'] 35 | ) 36 | end 37 | end 38 | end 39 | 40 | context 'with a proc' do 41 | let(:schema) do 42 | NxtSchema.schema(:developers) do 43 | required(:first_name, :String) 44 | omnipresent(:last_name, :String).maybe do |input| 45 | input.is_a?(NxtSchema::Undefined) 46 | end 47 | end 48 | end 49 | 50 | context 'when the method applies' do 51 | let(:input) { { first_name: 'Andy' } } 52 | 53 | it { expect(subject).to be_valid } 54 | 55 | it do 56 | expect( 57 | subject.output 58 | ).to match( 59 | first_name: 'Andy', 60 | last_name: instance_of(NxtSchema::Undefined) 61 | ) 62 | end 63 | end 64 | 65 | context 'when the method does not apply' do 66 | let(:input) { { first_name: 'Andy', last_name: nil } } 67 | 68 | it { expect(subject).to_not be_valid } 69 | 70 | it do 71 | expect(subject.errors).to eq( 72 | 'developers.last_name' => ['nil violates constraints (type?(String, nil) failed)'] 73 | ) 74 | end 75 | end 76 | end 77 | 78 | context 'when passing a simple value' do 79 | let(:schema) do 80 | NxtSchema.schema(:developers) do 81 | required(:first_name, :String) 82 | required(:last_name, :String).maybe(1) 83 | end 84 | end 85 | 86 | context 'when the method applies' do 87 | let(:input) { { first_name: 'Andy', last_name: 1 } } 88 | 89 | it { expect(subject).to be_valid } 90 | 91 | it do 92 | expect(subject.output).to eq(first_name: 'Andy', last_name: 1) 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /spec/node/on_evaluator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe NxtSchema do 4 | subject do 5 | schema.apply(input: input) 6 | end 7 | 8 | context 'with a method as condition' do 9 | let(:schema) do 10 | NxtSchema.schema(:developers) do 11 | required(:first_name, :String) 12 | required(:last_name, :String).on(:nil?, 'missing') 13 | end 14 | end 15 | 16 | context 'when the method applies' do 17 | let(:input) { { first_name: 'Andy', last_name: nil } } 18 | 19 | it { expect(subject).to be_valid } 20 | 21 | it do 22 | expect(subject.output).to eq(first_name: 'Andy', last_name: 'missing') 23 | end 24 | end 25 | 26 | context 'when the node is missing and thus the method does not apply' do 27 | let(:input) { { first_name: 'Andy' } } 28 | 29 | it { expect(subject).to_not be_valid } 30 | 31 | it do 32 | expect(subject.errors).to eq( 33 | 'developers' => ['The following keys are missing: [:last_name]'], 34 | 'developers.last_name' => ["NxtSchema::Undefined violates constraints (type?(String, NxtSchema::Undefined) failed)"] 35 | ) 36 | end 37 | end 38 | end 39 | 40 | context 'with a proc as condition' do 41 | let(:schema) do 42 | NxtSchema.schema(:developers) do 43 | required(:first_name, :String) 44 | omnipresent(:last_name, :String).on(->(input) { input.is_a?(NxtSchema::Undefined) }, ->(_input, application) { application.name.to_s } ) 45 | end 46 | end 47 | 48 | context 'when the method applies' do 49 | let(:input) { { first_name: 'Andy' } } 50 | 51 | it { expect(subject).to be_valid } 52 | 53 | it do 54 | expect(subject.output).to eq(first_name: 'Andy', last_name: 'last_name') 55 | end 56 | end 57 | 58 | context 'when the method does not apply' do 59 | let(:input) { { first_name: 'Andy', last_name: nil } } 60 | 61 | it { expect(subject).to_not be_valid } 62 | 63 | it do 64 | expect(subject.errors).to eq( 65 | 'developers.last_name' => ['nil violates constraints (type?(String, nil) failed)'] 66 | ) 67 | end 68 | end 69 | end 70 | 71 | context 'when passing a block as value' do 72 | let(:schema) do 73 | NxtSchema.schema(:developers) do 74 | required(:first_name, :String) 75 | required(:last_name, :String).on(true) do |_, application| 76 | "#{application.name} was not given" 77 | end 78 | end 79 | end 80 | 81 | context 'when the method applies' do 82 | let(:input) { { first_name: 'Andy', last_name: nil } } 83 | 84 | it { expect(subject).to be_valid } 85 | 86 | it do 87 | expect(subject.output).to eq(first_name: 'Andy', last_name: 'last_name was not given') 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/node/register_as_coerced_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe NxtSchema do 2 | subject { schema.apply(input: input) } 3 | 4 | let(:schema) do 5 | NxtSchema.schema(:person) do 6 | node(:first_name, :String) 7 | node(:last_name, :String) 8 | 9 | collection(:nick_names) do 10 | node(:nick_name, :String) 11 | end 12 | 13 | collection(:addresses) do 14 | schema(:address) do |address| 15 | address.node(:street, :String) 16 | address.node(:town, :String) 17 | end 18 | end 19 | 20 | optional(:phone, :String) 21 | end 22 | end 23 | 24 | let(:input) do 25 | { 26 | first_name: 'Andy', 27 | nick_names: ['Superman', 'Superstar', 1, 2.to_d, Object.new], 28 | addresses: nil, 29 | phone: 123 30 | } 31 | end 32 | 33 | it 'returns only nodes that could be coerced without errors' do 34 | expect(subject.coerced_nodes.map(&:input)).to match_array(%w[Andy Superman Superstar]) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/node/validations/equal_to_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe NxtSchema do 4 | subject do 5 | schema.apply(input: input) 6 | end 7 | 8 | let(:schema) do 9 | NxtSchema.schema(:person) do 10 | required(:first_name, :String) 11 | required(:last_name, :String).validate(:equal_to, 'Superstar') 12 | end 13 | end 14 | 15 | context 'when the input is valid' do 16 | let(:input) { { first_name: 'Andy', last_name: 'Superstar' } } 17 | 18 | it do 19 | expect(subject).to be_valid 20 | end 21 | end 22 | 23 | context 'when the input is not valid' do 24 | let(:input) { { first_name: 'Andy', last_name: 'Super Hero' } } 25 | 26 | it { expect(subject).to_not be_valid } 27 | 28 | it 'returns the correct errors' do 29 | expect(subject.errors).to eq("person.last_name"=>["Super Hero does not equal Superstar"]) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/node/validations/optional_node_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe NxtSchema do 4 | subject do 5 | schema.apply(input: input) 6 | end 7 | 8 | let(:schema) do 9 | NxtSchema.schema(:contact) do 10 | required(:first_name, :String) 11 | required(:last_name, :String) 12 | node(:email, :String, optional: ->(node) { node.up[:last_name].input == 'Superstar' }) 13 | end 14 | end 15 | 16 | context 'when the node is conditionally optional' do 17 | let(:input) do 18 | { 19 | first_name: 'Andy', 20 | last_name: 'Superstar' 21 | } 22 | end 23 | 24 | it { expect(subject).to be_valid } 25 | end 26 | 27 | context 'when the node is not conditionally optional' do 28 | let(:input) do 29 | { 30 | first_name: 'Andy', 31 | last_name: 'Other' 32 | } 33 | end 34 | 35 | it { expect(subject).to_not be_valid } 36 | 37 | it 'returns the correct errors' do 38 | expect(subject.errors).to eq("contact"=>["Required key :email is missing"]) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/node/validations/validate_with_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe NxtSchema do 4 | subject do 5 | schema.apply(input: input) 6 | end 7 | 8 | let(:schema) do 9 | NxtSchema.schema(:contact) do 10 | required(:first_name, :String) 11 | required(:age, :Integer).validate_with do 12 | validator(:greater_than, 18) && 13 | validator(:greater_than, 19) && 14 | validator(:less_than, 21) 15 | end 16 | end 17 | end 18 | 19 | context 'when the input violates some validators' do 20 | let(:input) { { first_name: 'Nils', age: 19 } } 21 | 22 | it { expect(subject).to_not be_valid } 23 | it { expect(subject.errors).to eq("contact.age" => ["19 must be greater than 19"]) } 24 | end 25 | 26 | context 'when the input violates all validators' do 27 | let(:input) { { first_name: 'Nils', age: 17 } } 28 | 29 | it { expect(subject).to_not be_valid } 30 | it { expect(subject.errors).to eq("contact.age" => ["17 must be greater than 18"]) } 31 | end 32 | 33 | context 'when the input violates none of the validators' do 34 | let(:input) { { first_name: 'Nils', age: 20 } } 35 | 36 | it { expect(subject).to be_valid } 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/node/validations/within_any_of_node_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe NxtSchema do 4 | subject do 5 | schema.apply(input: input) 6 | end 7 | 8 | let(:schema) do 9 | NxtSchema.any_of(:possible_combinations) do 10 | collection(:good_combinations) do 11 | validate(:attribute, :size, ->(s) { s > 5 }) 12 | 13 | any_of(:allowed_good_combinations) do 14 | schema(:good_combo) do 15 | required(:color, :String) 16 | required(:code, :Integer) 17 | end 18 | 19 | schema(:other_good_combo) do 20 | required(:length, :Decimal) 21 | end 22 | end 23 | end 24 | 25 | collection(:bad_combinations) do 26 | any_of(:allowed_bad_combinations) do 27 | schema(:bad_combo) do 28 | required(:color, :String) 29 | required(:code, :Integer) 30 | end 31 | 32 | schema(:other_bad_combo) do 33 | required(:height, :Decimal) 34 | end 35 | end 36 | end 37 | end 38 | end 39 | 40 | context 'when the input is not valid' do 41 | let(:input) do 42 | [ 43 | { color: 'blue', code: 1 }, 44 | { color: 'black', code: 2 }, 45 | { length: 12.to_d } 46 | ] 47 | end 48 | 49 | it { expect(subject).to_not be_valid } 50 | 51 | it 'returns the correct errors' do 52 | expect(subject.errors).to eq( 53 | "possible_combinations.good_combinations"=>["[{:color=>\"blue\", :code=>1}, {:color=>\"black\", :code=>2}, {:length=>0.12e2}] has invalid size attribute of 3"], 54 | "possible_combinations.bad_combinations.allowed_bad_combinations[2].bad_combo"=>["The following keys are missing: [:color, :code]"], 55 | "possible_combinations.bad_combinations.allowed_bad_combinations[2].bad_combo.color"=>["NxtSchema::Undefined violates constraints (type?(String, NxtSchema::Undefined) failed)"], 56 | "possible_combinations.bad_combinations.allowed_bad_combinations[2].bad_combo.code"=>["NxtSchema::Undefined violates constraints (type?(Integer, NxtSchema::Undefined) failed)"], 57 | "possible_combinations.bad_combinations.allowed_bad_combinations[2].other_bad_combo"=>["The following keys are missing: [:height]"], 58 | "possible_combinations.bad_combinations.allowed_bad_combinations[2].other_bad_combo.height"=>["NxtSchema::Undefined violates constraints (type?(BigDecimal, NxtSchema::Undefined) failed)"] 59 | ) 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/registry_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe NxtSchema::Registry do 2 | let(:test_class) do 3 | Class.new do 4 | include(NxtSchema::Registry) 5 | end 6 | end 7 | 8 | context 'register' do 9 | before do 10 | test_class.schemas.register( 11 | :create, 12 | NxtSchema.params do 13 | required(:first_name, :String) 14 | required(:last_name, :String) 15 | end 16 | ) 17 | end 18 | 19 | describe '#register' do 20 | it 'registers the schema' do 21 | expect(test_class.schemas.resolve!(:create)).to be_a(NxtSchema::Template::Schema) 22 | expect(test_class.schemas.resolve!(:create).sub_nodes.keys).to match_array([:first_name, :last_name]) 23 | end 24 | end 25 | 26 | describe '#register!' do 27 | before do 28 | test_class.schemas.register!( 29 | :create, 30 | NxtSchema.params do 31 | required(:first_name, :String) 32 | end 33 | ) 34 | end 35 | 36 | it 'overwrites the previous schema' do 37 | expect(test_class.schemas.resolve!(:create)).to be_a(NxtSchema::Template::Schema) 38 | expect(test_class.schemas.resolve!(:create).sub_nodes.keys).to match_array([:first_name]) 39 | end 40 | end 41 | end 42 | 43 | context 'apply' do 44 | before do 45 | test_class.schemas.register( 46 | :create, 47 | NxtSchema.params do 48 | required(:first_name, :String) 49 | required(:last_name, :String) 50 | end 51 | ) 52 | end 53 | 54 | describe '#apply' do 55 | it 'registers the schema' do 56 | expect(test_class.schemas.apply(:create, { first_name: 'Andy' })).to be_a(NxtSchema::Node::Schema) 57 | 58 | expect( 59 | test_class.schemas.apply!( 60 | :create, 61 | { first_name: 'Andy', last_name: 'Robecke' }) 62 | ).to eq( 63 | first_name: 'Andy', 64 | last_name: 'Robecke' 65 | ) 66 | end 67 | end 68 | 69 | describe '#apply!' do 70 | it 'applies the input' do 71 | expect( 72 | test_class.new.schemas.apply!( 73 | :create, 74 | { first_name: 'Andy', last_name: 'Robecke' } 75 | ) 76 | ).to eq( 77 | first_name: 'Andy', 78 | last_name: 'Robecke' 79 | ) 80 | end 81 | end 82 | end 83 | 84 | context 'inheritance' do 85 | let(:child_class) do 86 | Class.new(test_class) 87 | end 88 | 89 | before do 90 | test_class.schemas.register( 91 | :create, 92 | NxtSchema.params do 93 | required(:first_name, :String) 94 | required(:last_name, :String) 95 | end 96 | ) 97 | end 98 | 99 | it 'inherits the schemas to the subclass' do 100 | test_class.schemas.each do |key, schema| 101 | expect(child_class.new.schemas.resolve(key)).to eq(schema) 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "nxt_schema" 3 | require "method_profiler" 4 | require "pry" 5 | 6 | RSpec.configure do |config| 7 | # Enable flags like --only-failures and --next-failure 8 | config.example_status_persistence_file_path = ".rspec_status" 9 | 10 | # Disable RSpec exposing methods globally on `Module` and `main` 11 | config.disable_monkey_patching! 12 | 13 | config.expect_with :rspec do |c| 14 | c.syntax = :expect 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/template/apply_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe NxtSchema::Template::Base do 2 | let(:schema) do 3 | NxtSchema.nodes(:numbers) do 4 | validate(:attribute, :size, ->(s) { s > 3 }) 5 | required(:number, :Integer) 6 | end 7 | end 8 | 9 | subject { schema.apply!(input: input) } 10 | 11 | describe '#apply!' do 12 | context 'when the input is valid' do 13 | let(:input) { [1, 2, 3, 4] } 14 | 15 | it 'returns the output' do 16 | expect(subject).to eq(input) 17 | end 18 | end 19 | 20 | context 'when the input is not valid' do 21 | let(:input) { [1, 2, 3] } 22 | 23 | it 'raises an error' do 24 | expect { subject }.to raise_error(NxtSchema::Errors::Invalid) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/template/callable_type_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe NxtSchema::Template::Base do 2 | let(:schema) do 3 | NxtSchema.schema do 4 | multiply_type = lambda do |x| 5 | return x * x if x.is_a?(Integer) 6 | 7 | raise NxtSchema::Errors::CoercionError, "#{x} must be an integer" 8 | end 9 | 10 | required(:multiply, multiply_type) 11 | required(:name, :StrippedString) 12 | required(:dry_type, NxtSchema::Types::Strict::String.constructor(->(string) { string&.upcase })) 13 | end 14 | end 15 | 16 | context 'procs as types' do 17 | let(:subject) { schema.apply!(input: input) } 18 | 19 | context 'when the type can be applied' do 20 | let(:input) { { multiply: 12, name: 'Andy ', dry_type: 'upcase' } } 21 | 22 | it 'uses the proc as type' do 23 | expect(subject).to eq(multiply: 144, name: 'Andy', dry_type: 'UPCASE') 24 | end 25 | end 26 | 27 | context 'when the type cannot be applied' do 28 | let(:input) { { multiply: 12.to_d, name: 'Andy ', dry_type: 'upcase' } } 29 | 30 | it 'raises an error' do 31 | expect { subject }.to raise_error NxtSchema::Errors::Invalid, '{"roots.multiply"=>["12.0 must be an integer"]}' 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/template/has_sub_nodes/any_of/any_of_collection_of_schemas_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe NxtSchema do 2 | subject { schema.apply(input: input) } 3 | 4 | context 'any of collections of any of schemas' do 5 | let(:schema) do 6 | NxtSchema.any_of(:possible_combinations) do 7 | collection(:good_combinations) do 8 | any_of(:allowed_good_combinations) do 9 | schema(:good_combo) do 10 | required(:color, :String) 11 | required(:code, :Integer) 12 | end 13 | 14 | schema(:other_good_combo) do 15 | required(:length, :Decimal) 16 | end 17 | end 18 | end 19 | 20 | collection(:bad_combinations) do 21 | any_of(:allowed_bad_combinations) do 22 | schema(:bad_combo) do 23 | required(:color, :String) 24 | required(:code, :Integer) 25 | end 26 | 27 | schema(:other_bad_combo) do 28 | required(:height, :Decimal) 29 | end 30 | end 31 | end 32 | end 33 | end 34 | 35 | context 'when the input is valid' do 36 | let(:input) do 37 | [ 38 | { color: 'blue', code: 1 }, 39 | { color: 'black', code: 2 }, 40 | { length: 12.to_d } 41 | ] 42 | end 43 | 44 | it { expect(subject).to be_valid } 45 | 46 | it { expect(subject.output).to eq(input) } 47 | end 48 | 49 | context 'when the input is not valid' do 50 | let(:input) do 51 | [ 52 | { color: 'blue', code: 1 }, 53 | { color: 'black', code: 2 }, 54 | { length: 12.to_d }, 55 | { height: 12.to_d } 56 | ] 57 | end 58 | 59 | it { expect(subject).to_not be_valid } 60 | 61 | it do 62 | expect(subject.errors).to eq( 63 | "possible_combinations.good_combinations.allowed_good_combinations[3].good_combo"=>["The following keys are missing: [:color, :code]"], 64 | "possible_combinations.good_combinations.allowed_good_combinations[3].good_combo.color"=>["NxtSchema::Undefined violates constraints (type?(String, NxtSchema::Undefined) failed)"], 65 | "possible_combinations.good_combinations.allowed_good_combinations[3].good_combo.code"=>["NxtSchema::Undefined violates constraints (type?(Integer, NxtSchema::Undefined) failed)"], 66 | "possible_combinations.good_combinations.allowed_good_combinations[3].other_good_combo"=>["The following keys are missing: [:length]"], 67 | "possible_combinations.good_combinations.allowed_good_combinations[3].other_good_combo.length"=>["NxtSchema::Undefined violates constraints (type?(BigDecimal, NxtSchema::Undefined) failed)"], 68 | "possible_combinations.bad_combinations.allowed_bad_combinations[2].bad_combo"=>["The following keys are missing: [:color, :code]"], 69 | "possible_combinations.bad_combinations.allowed_bad_combinations[2].bad_combo.color"=>["NxtSchema::Undefined violates constraints (type?(String, NxtSchema::Undefined) failed)"], 70 | "possible_combinations.bad_combinations.allowed_bad_combinations[2].bad_combo.code"=>["NxtSchema::Undefined violates constraints (type?(Integer, NxtSchema::Undefined) failed)"], 71 | "possible_combinations.bad_combinations.allowed_bad_combinations[2].other_bad_combo"=>["The following keys are missing: [:height]"], 72 | "possible_combinations.bad_combinations.allowed_bad_combinations[2].other_bad_combo.height"=>["NxtSchema::Undefined violates constraints (type?(BigDecimal, NxtSchema::Undefined) failed)"] 73 | ) 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/template/has_sub_nodes/any_of/collection_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe NxtSchema do 2 | subject do 3 | schema.apply(input: input) 4 | end 5 | 6 | context 'any of within a collection' do 7 | let(:schema) do 8 | NxtSchema.collection(:scores) do |scores| 9 | scores.any_of(:score) do |score| 10 | score.node(:integer, :Integer) 11 | score.node(:string, :String) 12 | end 13 | end 14 | end 15 | 16 | context 'when all inputs match one of the schemas' do 17 | let(:input) { [1, '2', 3, 'vier'] } 18 | 19 | it { expect(subject).to be_valid } 20 | 21 | it 'returns the correct output' do 22 | expect(subject.output).to eq(input) 23 | end 24 | end 25 | 26 | context 'when some inputs do not match any of the schemas' do 27 | let(:input) { [1, '2', 3, 4.to_d, nil] } 28 | 29 | it { expect(subject).to_not be_valid } 30 | 31 | it 'returns the correct schema errors' do 32 | expect(subject.errors).to eq( 33 | "scores.score[3].integer"=>["0.4e1 violates constraints (type?(Integer, 0.4e1) failed)"], 34 | "scores.score[3].string"=>["0.4e1 violates constraints (type?(String, 0.4e1) failed)"], 35 | "scores.score[4].integer"=>["nil violates constraints (type?(Integer, nil) failed)"], 36 | "scores.score[4].string"=>["nil violates constraints (type?(String, nil) failed)"] 37 | ) 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/template/has_sub_nodes/any_of/leaf_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe NxtSchema do 2 | subject do 3 | schema.apply(input: input) 4 | end 5 | 6 | context 'any of leaf nodes' do 7 | let(:schema) do 8 | NxtSchema.schema(:scores) do 9 | any_of(:score) do 10 | node(:integer, :Integer) 11 | node(:string, :String) 12 | end 13 | end 14 | end 15 | 16 | context 'when the input matches one of the schemas' do 17 | let(:input) { { score: 1 } } 18 | 19 | it { expect(subject).to be_valid } 20 | 21 | it 'returns the correct output' do 22 | expect(subject.output).to eq(input) 23 | end 24 | end 25 | 26 | context 'when the input matches none of the schemas' do 27 | let(:input) { { score: 1.to_d } } 28 | 29 | it { expect(subject).to_not be_valid } 30 | 31 | it 'returns the correct schema errors' do 32 | expect(subject.errors).to eq( 33 | "scores.score.integer"=>["0.1e1 violates constraints (type?(Integer, 0.1e1) failed)"], 34 | "scores.score.string"=>["0.1e1 violates constraints (type?(String, 0.1e1) failed)"] 35 | ) 36 | end 37 | end 38 | end 39 | 40 | context 'without sub nodes' do 41 | it { expect { NxtSchema.any_of {} }.to raise_error NxtSchema::Errors::InvalidOptions } 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/template/has_sub_nodes/any_of/schema_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe NxtSchema do 2 | subject { schema.apply(input: input) } 3 | 4 | context 'any of multiple schemas' do 5 | let(:schema) do 6 | NxtSchema.any_of(:contacts) do |contact| 7 | contact.schema do 8 | required(:first_name, :String) 9 | required(:last_name, :String) 10 | required(:female, :Bool) 11 | end 12 | 13 | contact.schema do 14 | required(:first_name, :String) 15 | required(:last_name, :String) 16 | required(:male, :Bool) 17 | end 18 | end 19 | end 20 | 21 | context 'when the input matches one of the schemas' do 22 | let(:input) do 23 | { first_name: 'Andy', last_name: 'Superstar', male: true } 24 | end 25 | 26 | it { expect(subject).to be_valid } 27 | 28 | it 'returns the correct output' do 29 | expect(subject.output).to eq(input) 30 | end 31 | end 32 | 33 | context 'when the input does not match one of the schemas' do 34 | let(:input) { {} } 35 | 36 | it { expect(subject).to_not be_valid } 37 | 38 | it 'returns the correct schema errors' do 39 | expect(subject.errors).to eq( 40 | "contacts.0"=>["The following keys are missing: [:first_name, :last_name, :female]"], 41 | "contacts.0.first_name"=>["NxtSchema::Undefined violates constraints (type?(String, NxtSchema::Undefined) failed)"], 42 | "contacts.0.last_name"=>["NxtSchema::Undefined violates constraints (type?(String, NxtSchema::Undefined) failed)"], 43 | "contacts.0.female"=>["NxtSchema::Undefined violates constraints (type?(FalseClass, NxtSchema::Undefined) failed)"], 44 | "contacts.1"=>["The following keys are missing: [:first_name, :last_name, :male]"], 45 | "contacts.1.first_name"=>["NxtSchema::Undefined violates constraints (type?(String, NxtSchema::Undefined) failed)"], 46 | "contacts.1.last_name"=>["NxtSchema::Undefined violates constraints (type?(String, NxtSchema::Undefined) failed)"], 47 | "contacts.1.male"=>["NxtSchema::Undefined violates constraints (type?(FalseClass, NxtSchema::Undefined) failed)"] 48 | ) 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/template/has_sub_nodes/collection_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe NxtSchema do 4 | subject do 5 | schema.apply(input: input) 6 | end 7 | 8 | context 'array of leaf nodes' do 9 | let(:schema) do 10 | NxtSchema.collection(:developers) do |devs| 11 | devs.node(:dev, NxtSchema::Types::Strict::String | NxtSchema::Types::Coercible::Float) 12 | end 13 | end 14 | 15 | context 'when the input is valid' do 16 | let(:input) do 17 | ['Aki', 1, 2, 'Ito', '4.0', 12.to_d] 18 | end 19 | 20 | it 'returns the correct output' do 21 | expect(subject.output).to eq(['Aki', 1.0, 2.0, 'Ito', '4.0', 12.0]) 22 | end 23 | 24 | it { expect(subject).to be_valid } 25 | end 26 | 27 | context 'when the input violates the schema' do 28 | let(:input) do 29 | ['Andy', 1, 2, 3.0, BigDecimal(4), [1, 2], {}] 30 | end 31 | 32 | it 'returns the correct errors' do 33 | expect(subject).to_not be_valid 34 | 35 | expect(subject.errors).to eq( 36 | "developers.dev[5]"=>["can't convert Array into Float"], 37 | "developers.dev[6]"=>["can't convert Hash into Float"] 38 | ) 39 | end 40 | end 41 | end 42 | 43 | context 'array of arrays of nodes' do 44 | let(:schema) do 45 | NxtSchema.collection(:developers) do |developers| 46 | developers.nodes(:frontend_devs) do |frontend_devs| 47 | frontend_devs.schema(:frontend_dev) do |frontend_dev| 48 | frontend_dev.node(:name, :String) 49 | frontend_dev.node(:age, :Integer) 50 | end 51 | end 52 | end 53 | end 54 | 55 | context 'when the input is valid' do 56 | let(:input) do 57 | [ 58 | [{ name: 'Ben', age: 12 }, { name: 'Igor', age: 11 }], 59 | [{ name: 'Nils', age: 10 }, { name: 'Nico', age: 9 }] 60 | ] 61 | end 62 | 63 | it { expect(subject).to be_valid } 64 | 65 | it 'returns the correct output' do 66 | expect(subject.output).to eq(input) 67 | end 68 | end 69 | 70 | context 'when the input violates the schema' do 71 | let(:input) do 72 | [ 73 | [{ first_name: 'Ben', age: 12 }, { name: 'Igor', age: 11 }], 74 | [{ name: 'Nils', age: 10 }, { name: 'Nico', age: 9 }], 75 | [{ first_name: 'Andy' }, 'invalid', 1, 2], 76 | [] 77 | ] 78 | end 79 | 80 | it { expect(subject).to_not be_valid } 81 | 82 | it 'returns the correct errors' do 83 | expect(subject.errors).to eq( 84 | "developers.frontend_devs[0].frontend_dev[0]"=>["The following keys are missing: [:name]"], 85 | "developers.frontend_devs[0].frontend_dev[0].name"=>["NxtSchema::Undefined violates constraints (type?(String, NxtSchema::Undefined) failed)"], 86 | "developers.frontend_devs[2].frontend_dev[0]"=>["The following keys are missing: [:name, :age]"], 87 | "developers.frontend_devs[2].frontend_dev[0].name"=>["NxtSchema::Undefined violates constraints (type?(String, NxtSchema::Undefined) failed)"], 88 | "developers.frontend_devs[2].frontend_dev[0].age"=>["NxtSchema::Undefined violates constraints (type?(Integer, NxtSchema::Undefined) failed)"], 89 | "developers.frontend_devs[2].frontend_dev[1]"=>["\"invalid\" violates constraints (type?(Hash, \"invalid\") failed)"], 90 | "developers.frontend_devs[2].frontend_dev[2]"=>["1 violates constraints (type?(Hash, 1) failed)"], 91 | "developers.frontend_devs[2].frontend_dev[3]"=>["2 violates constraints (type?(Hash, 2) failed)"], 92 | "developers.frontend_devs[3]"=>["is not allowed to be empty"] 93 | ) 94 | end 95 | end 96 | end 97 | 98 | context 'without sub nodes' do 99 | it { expect { NxtSchema.collection {} }.to raise_error NxtSchema::Errors::InvalidOptions } 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /spec/template/has_sub_nodes/schema/additional_keys_strategy/allow_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe NxtSchema do 2 | subject do 3 | schema.apply(input: input) 4 | end 5 | 6 | context 'when additional keys are allowed' do 7 | let(:schema) do 8 | NxtSchema.schema(:task, additional_keys: :allow, type_system: NxtSchema::Types::Coercible) do |task| 9 | task.node(:name, :String) 10 | task.collection(:sub_tasks) do |sub_tasks| 11 | sub_tasks.schema(:sub_task) do |sub_task| 12 | sub_task.node(:name, :String) 13 | sub_task.node(:id, :Integer) 14 | end 15 | end 16 | end 17 | end 18 | 19 | let(:input) do 20 | { 21 | name: 'Do something', 22 | description: 'Will not be rejected', 23 | sub_tasks: [ 24 | { name: 'Do some more', id: '1' }, 25 | { name: 'Do some more', id: '2', estimate: '12 weeks' } 26 | ], 27 | meta: 'Can be anything' 28 | } 29 | end 30 | 31 | it { expect(subject).to be_valid } 32 | 33 | it 'returns the correct output' do 34 | expect(subject.output).to eq( 35 | { 36 | name: 'Do something', 37 | description: 'Will not be rejected', 38 | sub_tasks: [ 39 | { name: 'Do some more', id: 1 }, 40 | { name: 'Do some more', id: 2, estimate: '12 weeks' } 41 | ], 42 | meta: 'Can be anything' 43 | } 44 | ) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/template/has_sub_nodes/schema/additional_keys_strategy/reject_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe NxtSchema do 2 | subject do 3 | schema.apply(input: input) 4 | end 5 | 6 | context 'when additional keys are rejected' do 7 | let(:schema) do 8 | NxtSchema.schema(:task, additional_keys: :reject) do |task| 9 | task.node(:name, :String) 10 | task.collection(:sub_tasks) do |sub_tasks| 11 | sub_tasks.schema(:sub_task) do |sub_task| 12 | sub_task.node(:name, :String) 13 | sub_task.node(:description, :String) 14 | end 15 | end 16 | end 17 | end 18 | 19 | let(:input) do 20 | { 21 | name: 'Do something', 22 | description: 'Will be rejected', 23 | sub_tasks: [ 24 | { name: 'Do some more', description: 'do it carefully' }, 25 | { name: 'Do some more', description: 'do it carefully', estimate: 'We do not do estimates' } 26 | ] 27 | } 28 | end 29 | 30 | it { expect(subject).to be_valid } 31 | 32 | it 'rejects the additional keys' do 33 | expect(subject.output).to eq( 34 | { 35 | name: "Do something", 36 | sub_tasks: 37 | [ 38 | { name: "Do some more", description: "do it carefully" }, 39 | { name: "Do some more", description: "do it carefully" } 40 | ] 41 | } 42 | ) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/template/has_sub_nodes/schema/additional_keys_strategy/restrict_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe NxtSchema do 2 | subject do 3 | schema.apply(input: input) 4 | end 5 | 6 | context 'when additional keys are restricted' do 7 | let(:schema) do 8 | NxtSchema.schema(:task, additional_keys: :restrict) do |task| 9 | task.node(:name, :String) 10 | task.collection(:sub_tasks) do |sub_tasks| 11 | sub_tasks.schema(:sub_task) do |sub_task| 12 | sub_task.node(:name, :String) 13 | sub_task.node(:description, :String) 14 | end 15 | end 16 | end 17 | end 18 | 19 | let(:input) do 20 | { 21 | name: 'Do something', 22 | description: 'Will be rejected', 23 | sub_tasks: [ 24 | { name: 'Do some more', description: 'do it carefully' }, 25 | { name: 'Do some more', description: 'do it carefully', estimate: 'We do not do estimates' } 26 | ] 27 | } 28 | end 29 | 30 | it 'adds the correct errors' do 31 | expect(subject.errors).to eq( 32 | "task"=>["Additional keys are not allowed: [:description]"], 33 | "task.sub_tasks.sub_task[1]"=>["Additional keys are not allowed: [:estimate]"] 34 | ) 35 | 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/template/has_sub_nodes/schema/constructor_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe NxtSchema do 2 | subject { schema.apply(input: input) } 3 | 4 | context 'when a type is provided for a schema' do 5 | let(:schema) do 6 | NxtSchema.schema(:person, type: NxtSchema::Types::Constructor(::OpenStruct)) do |person| 7 | person.node(:first_name, :String) 8 | person.node(:last_name, :String) 9 | 10 | person.schema(:address, optional: true) do |address| 11 | address.node(:street, :String) 12 | address.node(:town, :String) 13 | end 14 | 15 | person.optional(:phone, :String) 16 | end 17 | end 18 | 19 | let(:input) do 20 | { 21 | first_name: 'Hanna', 22 | last_name: 'Robecke', 23 | address: { 24 | street: 'Am Waeldchen 9', 25 | town: 'Kaiserslautern' 26 | }, 27 | phone: '017696426299' 28 | } 29 | end 30 | 31 | it 'construct the objects' do 32 | expect(subject.output).to eq(OpenStruct.new(input)) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/template/has_sub_nodes/schema/input_preprocessor_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe NxtSchema do 2 | subject { schema.apply(input: input) } 3 | 4 | let(:input) do 5 | { 6 | 'first_name' => 'Hanna', 7 | last_name: 'Robecke', 8 | address: { 9 | 'street' => 'Am Waeldchen 9', 10 | town: 'Kaiserslautern' 11 | } 12 | } 13 | end 14 | 15 | describe '.preprocess_input' do 16 | context 'default input preprocessor' do 17 | let(:schema) do 18 | NxtSchema.schema(:person) do 19 | node(:first_name, :String) 20 | node(:last_name, :String) 21 | 22 | schema(:address, optional: true) do 23 | node(:street, :String) 24 | node(:town, :String) 25 | end 26 | 27 | optional(:phone, :String) 28 | end 29 | end 30 | 31 | it 'transforms all input keys of input hashes to symbols' do 32 | expect(subject).to be_valid 33 | end 34 | end 35 | 36 | context 'when switched off' do 37 | let(:schema) do 38 | NxtSchema.schema(:person, preprocess_input: false) do 39 | node(:first_name, :String) 40 | node(:last_name, :String) 41 | 42 | schema(:address, optional: true) do 43 | node(:street, :String) 44 | node(:town, :String) 45 | end 46 | 47 | optional(:phone, :String) 48 | end 49 | end 50 | 51 | it 'does not process inputs' do 52 | expect(subject).to_not be_valid 53 | expect( 54 | subject.errors 55 | ).to eq( 56 | "person"=>["The following keys are missing: [:first_name]"], 57 | "person.first_name"=>["NxtSchema::Undefined violates constraints (type?(String, NxtSchema::Undefined) failed)"], 58 | "person.address"=>["The following keys are missing: [:street]"], 59 | "person.address.street"=>["NxtSchema::Undefined violates constraints (type?(String, NxtSchema::Undefined) failed)"] 60 | ) 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/template/has_sub_nodes/schema/omni_present_nodes_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe NxtSchema do 2 | subject { schema.apply(input: input) } 3 | 4 | context 'with present nodes' do 5 | let(:schema) do 6 | NxtSchema.schema(:person) do |person| 7 | person.omnipresent(:first_name, :String).default('') 8 | person.omnipresent(:last_name, :String) 9 | end 10 | end 11 | 12 | context 'but the node is given already' do 13 | let(:input) do 14 | { first_name: 'Albert', last_name: 'Einstein' } 15 | end 16 | 17 | it { expect(subject).to be_valid } 18 | 19 | it { expect(subject.output).to eq(input) } 20 | end 21 | 22 | context 'and it is not present' do 23 | let(:input) { {} } 24 | 25 | it { expect(subject).to be_valid } 26 | 27 | it do 28 | expect(subject.output).to match( 29 | first_name: '', 30 | last_name: instance_of(NxtSchema::Undefined) 31 | ) 32 | end 33 | end 34 | 35 | context 'with nil as default value' do 36 | let(:schema) do 37 | NxtSchema.schema(:person) do |person| 38 | person.omnipresent(:first_name, :String).default(nil) 39 | person.omnipresent(:last_name, :String) 40 | end 41 | end 42 | 43 | let(:input) { {} } 44 | 45 | it { expect(subject).to_not be_valid } 46 | 47 | it { expect(subject.errors).to eq("person.first_name"=>["nil violates constraints (type?(String, nil) failed)"]) } 48 | 49 | it 'does not include the node with the default value as it is invalid' do 50 | expect(subject.output).to match(last_name: instance_of(NxtSchema::Undefined)) 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/template/has_sub_nodes/schema/optional_nodes_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe NxtSchema do 2 | subject { schema.apply(input: input) } 3 | 4 | context 'when some nodes are optional' do 5 | let(:schema) do 6 | NxtSchema.schema(:person) do |person| 7 | person.node(:first_name, :String) 8 | person.node(:last_name, :String) 9 | person.schema(:address, optional: true) do |address| 10 | address.node(:street, :String) 11 | address.node(:town, :String) 12 | end 13 | person.optional(:phone, :String) 14 | end 15 | end 16 | 17 | context 'and they are given' do 18 | let(:input) do 19 | { 20 | first_name: 'Hanna', 21 | last_name: 'Robecke', 22 | address: { 23 | street: 'Am Waeldchen 9', 24 | town: 'Kaiserslautern' 25 | }, 26 | phone: '017696426299' 27 | } 28 | end 29 | 30 | it { expect(subject).to be_valid } 31 | 32 | it do 33 | expect(subject.output).to eq(input) 34 | end 35 | end 36 | 37 | context 'and they are not given' do 38 | let(:input) do 39 | { 40 | first_name: 'Hanna', 41 | last_name: 'Robecke' 42 | } 43 | end 44 | 45 | it { expect(subject).to be_valid } 46 | 47 | it do 48 | expect(subject.output).to eq(input) 49 | end 50 | end 51 | end 52 | 53 | context 'when all nodes within a schema are optional' do 54 | let(:schema) do 55 | NxtSchema.schema(:person) do |person| 56 | person.optional(:first_name, :String) 57 | person.optional(:last_name, :String) 58 | person.schema(:address, optional: true) do |address| 59 | address.node(:street, :String) 60 | address.node(:town, :String) 61 | end 62 | end 63 | end 64 | 65 | context 'and some nodes are given' do 66 | let(:input) do 67 | { 68 | first_name: 'Andy', 69 | schema: { street: 'Am Waeldchen 9', town: 'Kaiserslautern' } 70 | } 71 | end 72 | 73 | it { expect(subject).to be_valid } 74 | end 75 | 76 | context 'and an empty hash is given' do 77 | let(:input) { {} } 78 | 79 | it { expect(subject).to be_valid } 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/template/has_sub_nodes/schema/transform_keys_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe NxtSchema do 2 | subject { schema.apply(input: input) } 3 | 4 | context 'when some nodes are optional' do 5 | let(:schema) do 6 | NxtSchema.schema(:person, transform_output_keys: ->(key) { key.to_s.upcase }) do |person| 7 | person.node(:first_name, :String) 8 | person.node(:last_name, :String) 9 | 10 | person.schema(:address, optional: true) do |address| 11 | address.node(:street, :String) 12 | address.node(:town, :String) 13 | end 14 | 15 | person.optional(:phone, :String) 16 | end 17 | end 18 | 19 | let(:input) do 20 | { 21 | 'first_name' => 'Hanna', 22 | last_name: 'Robecke', 23 | address: { 24 | 'street' => 'Am Waeldchen 9', 25 | town: 'Kaiserslautern' 26 | } 27 | } 28 | end 29 | 30 | it do 31 | expect(subject.output).to eq( 32 | "FIRST_NAME" => "Hanna", 33 | "LAST_NAME" => "Robecke", 34 | "ADDRESS" => {"STREET"=>"Am Waeldchen 9", "TOWN"=>"Kaiserslautern"} 35 | ) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/template/has_sub_nodes/schema_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe NxtSchema do 2 | subject { schema.apply(input: input) } 3 | 4 | context 'hash with leaf nodes' do 5 | let(:schema) do 6 | NxtSchema.schema(:company, type_system: NxtSchema::Types::Coercible) do |company| 7 | company.node(:name, :String) 8 | company.node(:value, :Decimal) 9 | end 10 | end 11 | 12 | context 'when the input is valid' do 13 | let(:input) do 14 | { name: 'Getsafe', value: '10_000_000_000' } 15 | end 16 | 17 | it { expect(subject).to be_valid } 18 | 19 | it 'returns the correct output' do 20 | expect(subject.output).to eq( 21 | name: 'Getsafe', 22 | value: 10_000_000_000.to_d 23 | ) 24 | end 25 | end 26 | 27 | context 'when the input violates the schema' do 28 | let(:input) do 29 | { name: 'Getsafe', value: 'a lot' } 30 | end 31 | 32 | it { expect(subject).to_not be_valid } 33 | 34 | it 'returns the correct output' do 35 | expect(subject.errors).to eq("company.value"=>["invalid value for BigDecimal(): \"a lot\""]) 36 | end 37 | end 38 | end 39 | 40 | context 'hash with hash nodes' do 41 | let(:schema) do 42 | NxtSchema.schema(:company) do |company| 43 | company.node(:name, :String) 44 | company.node(:customers, :Integer) 45 | company.schema(:address) do |address| 46 | address.node(:street, :String) 47 | address.node(:street_number, :Integer) 48 | address.node(:zip_code, :Integer) 49 | end 50 | end 51 | end 52 | 53 | context 'when the input is valid' do 54 | let(:input) do 55 | { 56 | name: 'Getsafe', 57 | customers: 10_000_000, 58 | address: { street: 'Langer Anger', street_number: 7, zip_code: 67661 } 59 | } 60 | end 61 | 62 | it { expect(subject).to be_valid } 63 | 64 | it 'returns the correct output' do 65 | expect(subject.output).to eq(input) 66 | end 67 | end 68 | 69 | context 'when the input violates the schema' do 70 | let(:input) do 71 | { name: 'Getsafe', customers: 'a lot' } 72 | end 73 | 74 | it { expect(subject).to_not be_valid } 75 | 76 | it 'returns the correct output' do 77 | expect(subject.errors).to eq( 78 | "company"=>["The following keys are missing: [:address]"], 79 | "company.customers"=>["\"a lot\" violates constraints (type?(Integer, \"a lot\") failed)"], 80 | "company.address"=>["NxtSchema::Undefined violates constraints (type?(Hash, NxtSchema::Undefined) failed)"] 81 | ) 82 | end 83 | end 84 | end 85 | 86 | context 'hash with array of nodes' do 87 | let(:schema) do 88 | NxtSchema.schema(:person) do |person| 89 | person.node(:name, :String) 90 | person.collection(:houses) do |houses| 91 | houses.schema(:house) do |address| 92 | address.node(:street, :String) 93 | address.node(:street_number, :Integer) 94 | address.node(:zip_code, :Integer) 95 | end 96 | end 97 | end 98 | end 99 | 100 | context 'when the input is valid' do 101 | let(:input) do 102 | { 103 | name: 'Nils', 104 | houses: [ 105 | { street: 'Langer Anger', street_number: 7, zip_code: 67661 }, 106 | { street: 'Kirchgasse', street_number: 1, zip_code: 68150 }, 107 | { street: 'Kimmelgarten', street_number: 11, zip_code: 67661 } 108 | ] 109 | } 110 | end 111 | 112 | it { expect(subject).to be_valid } 113 | 114 | it 'returns the correct output' do 115 | expect(subject.output).to eq(input) 116 | end 117 | end 118 | 119 | context 'when the input violates the schema' do 120 | let(:input) do 121 | { 122 | houses: [ 123 | { street: 'Langer Anger', street_number: 7 }, 124 | { street: nil, street_number: 1, zip_code: 68150 }, 125 | { street: 1, street_number: 11, zip_code: '67661' } 126 | ] 127 | } 128 | end 129 | 130 | it { expect(subject).to_not be_valid } 131 | 132 | it 'returns the correct errors' do 133 | expect(subject.errors).to eq( 134 | "person"=>["The following keys are missing: [:name]"], 135 | "person.name"=>["NxtSchema::Undefined violates constraints (type?(String, NxtSchema::Undefined) failed)"], 136 | "person.houses.house[0]"=>["The following keys are missing: [:zip_code]"], 137 | "person.houses.house[0].zip_code"=>["NxtSchema::Undefined violates constraints (type?(Integer, NxtSchema::Undefined) failed)"], 138 | "person.houses.house[1].street"=>["nil violates constraints (type?(String, nil) failed)"], 139 | "person.houses.house[2].street"=>["1 violates constraints (type?(String, 1) failed)"], 140 | "person.houses.house[2].zip_code"=>["\"67661\" violates constraints (type?(Integer, \"67661\") failed)"] 141 | ) 142 | end 143 | end 144 | end 145 | 146 | context 'without sub nodes' do 147 | it { expect { NxtSchema.schema {} }.to raise_error NxtSchema::Errors::InvalidOptions } 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /spec/template/path_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe NxtSchema do 2 | subject { node.path } 3 | 4 | context 'hash with hash nodes' do 5 | let(:schema) do 6 | NxtSchema.schema(:company) do |company| 7 | company.node(:name, :String) 8 | company.node(:customers, :Integer) 9 | company.schema(:address) do |address| 10 | address.node(:street, :String) 11 | address.node(:street_number, :Integer) 12 | address.node(:zip_code, :Integer) 13 | end 14 | end 15 | end 16 | 17 | describe '#path' do 18 | let(:node) { schema[:address][:street] } 19 | 20 | it { expect(subject).to eq('company.address.street') } 21 | end 22 | end 23 | 24 | context 'hash with array of nodes' do 25 | let(:schema) do 26 | NxtSchema.schema(:person) do |person| 27 | person.node(:name, :String) 28 | person.collection(:houses) do |houses| 29 | houses.schema(:house) do |address| 30 | address.node(:street, :String) 31 | address.node(:street_number, :Integer) 32 | address.node(:zip_code, :Integer) 33 | end 34 | end 35 | end 36 | end 37 | 38 | describe '#path' do 39 | describe '#path' do 40 | let(:node) { schema[:houses][:house][:street] } 41 | 42 | it { expect(subject).to eq('person.houses.house.street') } 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/type_resolver_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe NxtSchema::Template::TypeResolver do 2 | context 'with default type system' do 3 | context 'when the type exists in the default type system' do 4 | let(:schema) do 5 | NxtSchema.schema do 6 | required(:name, :String) 7 | end 8 | end 9 | 10 | subject { schema.sub_nodes[:name].type } 11 | 12 | it 'resolves the type' do 13 | expect(subject).to be_a(Dry::Types::Constrained) 14 | end 15 | end 16 | 17 | context 'when the type was registered' do 18 | let(:schema) do 19 | NxtSchema.schema do 20 | required(:name, :StrippedString) 21 | end 22 | end 23 | 24 | subject { schema.sub_nodes[:name].type } 25 | 26 | it 'resolves the type' do 27 | expect(subject).to be_a(Dry::Types::Constructor) 28 | end 29 | end 30 | 31 | context 'when the type cannot be resolved' do 32 | let(:schema) do 33 | NxtSchema.schema do 34 | required(:name, :DoesNotExist) 35 | end 36 | end 37 | 38 | subject { schema.sub_nodes[:name].type } 39 | 40 | it 'raises an error' do 41 | expect { subject }.to raise_error(ArgumentError, /Can't resolve type: .*/) 42 | end 43 | end 44 | end 45 | 46 | context 'with custom type system' do 47 | context 'when the type exists in the custom type system' do 48 | let(:schema) do 49 | NxtSchema.schema(type_system: NxtSchema::Types::JSON) do 50 | required(:date, :Date) 51 | end 52 | end 53 | 54 | subject { schema.sub_nodes[:date].type } 55 | 56 | it 'resolves the type' do 57 | expect(subject).to be_a(Dry::Types::Constructor) 58 | end 59 | end 60 | 61 | context 'when the type was registered' do 62 | let(:schema) do 63 | NxtSchema.schema(type_system: NxtSchema::Types::JSON) do 64 | required(:name, :StrippedString) 65 | end 66 | end 67 | 68 | subject { schema.sub_nodes[:name].type } 69 | 70 | it 'resolves the type' do 71 | expect(subject).to be_a(Dry::Types::Constructor) 72 | end 73 | end 74 | 75 | context 'when the type cannot be resolved' do 76 | let(:schema) do 77 | NxtSchema.schema(type_system: NxtSchema::Types::JSON) do 78 | required(:name, :DoesNotExist) 79 | end 80 | end 81 | 82 | subject { schema.sub_nodes[:name].type } 83 | 84 | it 'raises an error' do 85 | expect { subject }.to raise_error(ArgumentError, /Can't resolve type: .*/) 86 | end 87 | end 88 | end 89 | end 90 | --------------------------------------------------------------------------------