{"id":22445848,"url":"https://github.com/nxt-insurance/nxt_schema","last_synced_at":"2025-08-01T20:31:17.579Z","repository":{"id":37905049,"uuid":"206379890","full_name":"nxt-insurance/nxt_schema","owner":"nxt-insurance","description":"A schema that suits them all","archived":false,"fork":false,"pushed_at":"2024-04-25T07:23:56.000Z","size":491,"stargazers_count":14,"open_issues_count":12,"forks_count":0,"subscribers_count":13,"default_branch":"master","last_synced_at":"2024-04-25T08:29:43.528Z","etag":null,"topics":["dry-rb","dry-types","dry-validations","params","schema","validations"],"latest_commit_sha":null,"homepage":"","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/nxt-insurance.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE.txt","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null}},"created_at":"2019-09-04T17:55:01.000Z","updated_at":"2024-04-25T08:29:49.964Z","dependencies_parsed_at":"2024-04-25T08:29:46.989Z","dependency_job_id":"6efe1b70-5463-40f1-a599-a60e3c01a730","html_url":"https://github.com/nxt-insurance/nxt_schema","commit_stats":null,"previous_names":[],"tags_count":6,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nxt-insurance%2Fnxt_schema","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nxt-insurance%2Fnxt_schema/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nxt-insurance%2Fnxt_schema/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nxt-insurance%2Fnxt_schema/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/nxt-insurance","download_url":"https://codeload.github.com/nxt-insurance/nxt_schema/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":228402239,"owners_count":17914230,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["dry-rb","dry-types","dry-validations","params","schema","validations"],"created_at":"2024-12-06T03:17:02.425Z","updated_at":"2024-12-06T03:17:03.242Z","avatar_url":"https://github.com/nxt-insurance.png","language":"Ruby","funding_links":[],"categories":[],"sub_categories":[],"readme":"# NxtSchema\n\n## Installation\n\nAdd this line to your application's Gemfile:\n\n```ruby\ngem 'nxt_schema'\n```\n\nAnd then execute:\n\n    $ bundle\n\nOr install it yourself as:\n\n    $ gem install nxt_schema\n\n## What is it for?\n\nNxtSchema is a type coercion and validation framework that allows you to coerce and validate arbitrary nested \nstructures of data. The original idea is taken from https://dry-rb.org/gems/dry-schema and \nhttps://dry-rb.org/gems/dry-validation from the amazing dry.rb eco system. In contrast to dry-schema, \nNxtSchema aims to be a simpler solution that hopefully is easier to understand and debug. \nIt also ships with some handy features that dry-schema does not implement. \n\n### Usage\n\n```ruby\nPERSON = NxtSchema.schema(:person) do\n  node(:first_name, :String)\n  node(:last_name, :String)\n  node(:email, :String, optional: true).validate(:includes, '@')\nend\n\ninput = {\n  first_name: 'Ändy',\n  last_name: 'Robecke',\n  email: 'andreas@robecke.de'\n}\n\nresult = PERSON.apply(input: input)\n\nresult.valid? # =\u003e true\nresult.output # =\u003e input\n```  \n\n### Nodes\n\nA schema consists of a number of nodes. Every node has a name and an associated type for casting it's input when the \nschema is applied. Schemas can consist of 4 different kinds of nodes: \n\n```ruby\nNxtSchema::Node::Schema # =\u003e Hash of values \nNxtSchema::Node::Collection # =\u003e Array of values\nNxtSchema::Node::AnyOf # =\u003e Any of the defined schemas\nNxtSchema::Node::Leaf # =\u003e Node without sub nodes\n```\n\nThe kind of node dictates how the schema is applied to the input. On the root level the following methods are available\nto create schemas:\n\n```ruby\n  NxtSchema.schema { ... } # =\u003e Creates a schema node \n  NxtSchema.collection { ... } # =\u003e Creates an array of nodes\n  NxtSchema.any_of { ... } # =\u003e Creates a collection of allowed schemas\n```\n\n#### Node predicate aliases\n\nOf course these nodes can be combined and nested in arbitrary manner. When defining nodes within a schema, nodes are \nalways required per default. You can create nodes with the node method or several useful helper methods. \n\n```ruby\nNxtSchema.schema(:person) do\n  required(:first_name, :String) # =\u003e same as node(:first_name, :String)\n  optional(:last_name, :String) # =\u003e same as node(:first_name, :String, optional: true)\n  omnipresent(:email, :String) # =\u003e same as node(:first_name, :String, omnipresent: true)\nend\n```\n\n**NOTE: The methods above only apply to the keys of your schema and do not make any assumptions about values!**\n\nIn other word this means that making a node optional only makes your node optional. When your input contains the key but\nthe value is nil, you will still get an error in case there is no default or maybe expression that applies. Omnipresent\nnode also only inject the node into the schema but do not inject a default value. In order to inject a key with value \ninto a schema you also have to combine the node predicates with default value method described below. For clarification\ncheck out the examples below:\n\n```ruby\n# Optional node without default value\n\nschema = NxtSchema.schema(:person) do\n  optional(:email, :String)\nend\n\nresult = schema.apply(input: { email: nil })\nresult.errors # =\u003e {\"person.email\"=\u003e[\"nil violates constraints (type?(String, nil) failed)\"]}\nresult.output # =\u003e {:email=\u003enil}\n\nresult = schema.apply(input: {})\nresult.errors # =\u003e {}\nresult.output # =\u003e {}\n```\n\n```ruby\n# Optional node with default value\n\nschema = NxtSchema.schema(:person) do\n  optional(:email, :String).default('andreas@robecke.de')\nend\n\nresult = schema.apply(input: { email: nil })\nresult.errors # =\u003e {}\nresult.output # =\u003e {:email=\u003e\"andreas@robecke.de\"}\n\nresult = schema.apply(input: {})\nresult.errors # =\u003e {}\nresult.output # =\u003e {}\n```\n\n```ruby\n# Omnipresent node without default value\n\nschema = NxtSchema.schema(:person) do\n  omnipresent(:email, :String)\nend\n\nresult = schema.apply(input: {})\nresult.errors # =\u003e {}\nresult.output # =\u003e {:email=\u003eNxtSchema::Undefined}\n```\n\n```ruby\n# Omnipresent node with default value and maybe expression to allow default value to break type contract.\n\nschema = NxtSchema.schema(:person) do\n  omnipresent(:email, :String).default(nil).maybe(:nil?)\nend\n\nresult = schema.apply(input: {})\nresult.errors # =\u003e {}\nresult.output # =\u003e {:email=\u003enil}\n\nresult = schema.apply(input: { email: 'andreas@robecke.de' })\nresult.errors # =\u003e {}\nresult.output # =\u003e {:email=\u003e\"andreas@robecke.de\"}\n```\n\n##### Conditionally optional nodes\n\nYou can also pass a proc as the optional option. This is a shortcut for adding a validation to the parent node \nthat will result in a validation error in case the optional condition does not apply and the parent node does not \ncontain a sub node with that name (here contact schema not including an email node). \n\n```ruby\nschema = NxtSchema.schema(:contact) do\n  required(:first_name, :String)\n  required(:last_name, :String)\n  node(:email, :String, optional: -\u003e(node) { node.up[:last_name].input == 'Robecke' })\nend\n\nresult = schema.apply(input: { first_name: 'Andy', last_name: 'Other' })\nresult.errors # =\u003e {\"contact\"=\u003e[\"Required key :email is missing\"]}\n\nresult = schema.apply(input: { first_name: 'Andy', last_name: 'Robecke' })\nresult.errors # =\u003e {}\n```  \n\n#### Combining Schemas\n\nYou can also simply reuse a schema by passing it to the node method as the type of a node. When doing so the schema \nwill be cloned with the same options and configuration as the schema passed in. \n\n```ruby\nADDRESS = NxtSchema.schema(:address) do\n  required(:street, :String)\n  required(:town, :String)\n  required(:zip_code, :String)\nend \n\nPERSON = NxtSchema.schema(:person) do\n  required(:first_name, :String)\n  required(:last_name, :String)\n  optional(:address, ADDRESS)\nend\n```\n\n### Types\n\nThe type system is built with dry-types from the amazing https://dry-rb.org eco system. Even though dry-types also\noffers features such as default values for types as well as maybe types, these features are built directly into \nNxtSchema. \n\nIn NxtSchema every node has a type and you can either provide a symbol that will be resolved \nthrough the type system of the schema or you can directly provide an instance of dry type and thus use your \ncustom types. This means you can basically build any kind of objects such as structs and models from your data and \nyou are not limited to just hashes arrays and primitives.  \n\n#### Default type system\n\nYou can tell your schema which default type system it should use. Dry-Types comes with a few built in type systems.\nPer default NxtSchema will use nominal types if not specified otherwise. If the type cannot be resolved from the default\ntype system that was specified NxtSchema will always fallback to nominal types. In theory you can provide\na separate type system per node if that's what you need. \n                               \n```ruby\nNxtSchema.schema do\n  required(:test, :String) # The :String will resolve to NxtSchema::Types::Nominal::String\nend\n\nNxtSchema.schema(type_system: NxtSchema::Types::JSON) do\n  required(:test, :Date) # The :Date will resolve to NxtSchema::Types::JSON::Date\n  # When the type does not exist in the default type system (there is non JSON::String) we fallback to nominal types\n  required(:test, :String) \nend\n```\n\n#### NxtSchema.params\n\nNxtSchema.params will give you a schema as root node with NxtSchema::Types::Params as default type system.\nThis is suitable to validate and coerce your query params. \n\n```ruby\nNxtSchema.params do\n  required(:effective_at, :DateTime) # would resolve to Types::Params::DateTime \n  required(:test, :String) # The :String will resolve to NxtSchema::Types::Nominal::String\n  required(:advanced, NxtSchema::Types::Registry::Bool) # long version of required(:advanced, :Bool)\nend\n```\n\n#### NxtSchema::Registry\n\nTo make use of NxtSchema.params in your controller you can simply include the `NxtSchema::Registry` to easily register \nand apply schemas: \n\n```ruby\nclass UsersController \u003c ApplicationController\n  include NxtSchema::Registry\n  \n  # register the schema for the :create action\n  schemas.register(\n    :create, \n    NxtSchema.params do\n      required(:first_name, :String)\n      required(:last_name, :String)\n    end\n  )\n  \n  def create\n    User.create!(**create_params) \n  end\n  \n  private\n\n  def create_params\n    # apply the registered schema  \n    schemas.apply!(:create, params.fetch(:user))\n  end\nend\n\n```\n\n#### Custom types\n\nYou can also register custom types. In order to check out all the cool things you can do with dry types you should \ncheck out dry-types on https://dry-rb.org. But here is how you can add a type to the `NxtSchema::Types` module. \n\n```ruby\nNxtSchema.register_type(\n  :MyCustomStrippedString,\n  NxtSchema::Types::Strict::String.constructor(-\u003e(string) { string\u0026.strip })\n)\n\n# once registered you can use the type in your schema\n\nNxtSchema.schema(:company) do\n  required(:name, :MyCustomStrippedString)\nend\n```\n\n### Values\n\n#### Default values\n\n```ruby\n# Define default values with the default method\nrequired(:test, :DateTime).default(nil)\nrequired(:test, :DateTime).default(-\u003e { Time.current })\n```\n\n#### Maybe values \n\nWith maybe expressions you can halt coercion and allow your values to break the type contract. \n**Note: This means that your output will simply be set to the input without coercing the value!**\n\n```ruby\n# Define maybe values (values that do not match the type)\nrequired(:test, :String).maybe(:nil?)\n\nnodes(:tests).maybe(:empty?) do # will allow the collection to be empty and thus not contain strings\n  required(:test, :String)\nend\n\n```  \n\n### Validations\n\nNxtSchema comes with a simple validation system and ships with a small set of useful validators. Every node in a schema\nimplements the `:validate` method. Similar to ActiveModel::Validations it allows you to simply add errors to a node\nbased on some condition. When the node is yielded to your validation proc you have access to the nodes input with\n`node.input` and `node.index` when the node is within a collection of nodes as well as `node.name`. Furthermore you have \naccess to the context that was passed in when defining the schema or passed to the apply method later.\n\n**NOTE: Validations only run when no maybe expression applies and the node input could be coerced successfully**\n\n```ruby\n  # Simple custom validation\n  required(:test, :String).validate(-\u003e (node) { node.add_error(\"#{node.input} is not valid\") if node.input == 'not allowed' })\n  # Built in validations\n  required(:test, :String).validate(:attribute, :size, -\u003e(s) { s \u003c 7 }) \n  required(:test, :String).validate(:equal_to, 'same') \n  required(:test, :String).validate(:excluded_in, %w[not_allowed]) # excluded in the target: %w[not_allowed]\n  required(:test, :String).validate(:included_in, %w[allowed]) # included in the target: %w[allowed]\n  required(:test, :Array).validate(:excludes, 'excluded') # array value itself must exclude 'excluded' \n  required(:test, :Array).validate(:includes, 'included') # array value itself must include 'included'\n  required(:test, :Integer).validate(:greater_than, 1) \n  required(:test, :Integer).validate(:greater_than_or_equal, 1) \n  required(:test, :Integer).validate(:less_than, 1) \n  required(:test, :Integer).validate(:less_than_or_equal, 1) \n  required(:test, :String).validate(:pattern, /\\A.*@.*\\z/) \n  required(:test, :String).validate(:query, :present?) \n```\n\n#### Custom validators\n\nYou can also register your custom validators. Therefore you can simply implement a class that returns a lambda on build.\nThis lambda will be called with the node the validations runs on and it's input value. Except this, you are free in \nhow your validator can be used. Check out the specs for some examples. \n\n```ruby\nclass MyCustomExclusionValidator\n  def initialize(target)\n    @target = target\n  end\n\n  attr_reader :target\n\n  def build\n    lambda do |node, value|\n      if target.exclude?(value)\n        true\n      else\n        node.add_error(\"#{target} should not contain #{value}\")\n        false # validators must return false in the bad case (add_error already does this as per default)\n      end\n    end\n  end\nend\n\n# Register your validators  \nNxtSchema.register_validator(MyCustomExclusionValidator, :my_custom_exclusion_validator)\n\n# and then simply reference it with the key you've registered it\nschema = NxtSchema.schema(:company) do\n  requires(:name, :String).validate(:my_custom_exclusion_validator, %w[lemonade])\nend\n\nschema.apply(name: 'lemonade').valid? # =\u003e false\n```\n\n#### Validation messages\n\n- Allow to specify a path to translations\n- Add translated errors\n- Interpolate with actual vs. expected \n\n#### Combining validators\n\n`node(:test, String).validate(...)` basically adds a validator to the node. Of course you can add multiple validators.\nBut that means that they will all be executed. If you want your validator to only run in case \nanother was false, you can use `:validat_with do ... end` in order to combine validators based on custom logic. \n\n ```ruby\nNxtSchema.schema do\n  required(:test, :Integer).validate_with do\n    validator(:greater_than, 5) \u0026\u0026\n      validator(:greater_than, 6) ||\n      validator(:greater_than, 7)\n  end\nend\n```\n\nNote that this will not run subsequent validators once one was valuated to false and thus might not contain all error \nmessages of all validators that would have failed.  \n\n\n### Schema options\n \n#### Optional keys strategies\n\nSchemas in NxtSchema only look at the keys that you have defined in your schema, others are ignored per default.\nYou can change this behaviour by providing a strategy for the `:additional_keys` option. \n\n```ruby\n# This will simply ignore any other key except test \nNxtSchema.schema(additional_keys: :ignore) do\n  required(:test, :String) \nend\n\n# This would give you an error in case you apply anything other than { test: '...' }\nNxtSchema.schema(additional_keys: :restrict) do\n  required(:test, :String) \nend\n\n# This will merge other keys into your output\nschema = NxtSchema.schema(additional_keys: :allow) do\n  required(:test, :String) \nend\n\nschema.apply(input: {test: 'getsafe', other: 'Heidelberg'})\nschema.valid? # =\u003e true\nschema.value # =\u003e { test: 'getsafe', other: 'Heidelberg' }\n```\n\n#### Transform keys\n\nTo transform the keys of your output simply specify the transform_output_keys option. This might be useful\nwhen you want your schema to return only symbolized keys for example. \n\n```ruby\nschema = NxtSchema.schema(transform_output_keys: -\u003e(key) { key.to_sym }) do\n  required(:test, :String)\nend\n\nschema.apply(input: { 'test' =\u003e 'getsafe' }) # =\u003e {:test=\u003e\"getsafe\"}\nschema.apply(input: { test: 'getsafe' }) # =\u003e {:test=\u003e\"getsafe\"}\n``` \n\n#### Adding meta data to nodes\n\nYou want to give nodes an ID or some other meta data? You can use the meta method on nodes for adding additional \ninformation onto any node.  \n\n```ruby\nschema = NxtSchema.schema do\n  ERROR_MESSAGES = {\n    test: 'This is always broken'\n  }\n\n  required(:test, :String).meta(ERROR_MESSAGES).validate -\u003e(node) { node.add_error(node.meta.fetch(node.name)) }\nend\n\nschema.apply(input: { test: 'getsafe' }) \nschema.error #  {\"root.test\"=\u003e[\"This is always broken\"]}\n```\n\n#### Contexts\n\nWhen defining a schema it is possible to pass in a context option. This can be anything that you would like to access\nduring building your schema. A context could provide custom validators or default values depending of the name of your\nnodes for instance.  \n\n##### Build time\n\n```ruby\ncontext = OpenStruct.new(email_validator: -\u003e(node) { node.input \u0026\u0026 node.input.includes?('@') }) \n\nNxtSchema.schema(:developers, context: context) do\n  required(:first_name, :String)\n  required(:last_name, :String)\n  required(:email, :String).validate(context.email_validator)\nend\n```\n\n##### Apply time\n\nYou can also pass in a context at apply time. If you do not pass in a specific \ncontext at apply time you can still access the context passed in at build time. \nBasically passing in a context at apply time will overwrite the context from before. You can access it simply through\nthe node.  \n\n```ruby\nbuild_context = OpenStruct.new(email_validator: -\u003e(node) { node.input.includes?('@') })\napply_context = OpenStruct.new(default_role: 'BOSS')\n\nschema = NxtSchema.schema(:developers, context: build_context) do\n  # context at build time\n  required(:email, :String).validate(context.email_validator) # \n  # access the context at apply time through the node \n  required(:role, :String).default { |_, node| node.context.default_role }\nend\n\nschema.apply(input: input, context: apply_context)\n```\n\n## Development\n\nAfter 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.\n\nTo 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).\n\n## Contributing\n\nBug reports and pull requests are welcome on GitHub at https://github.com/getand.\n\n## License\n\nThe gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).\n\n## TODO:\n\n- Explain node interface\n- Add apply! method to readme\n- Allow to disable validation when applying \n    --\u003e Are there attributes that should be moved to apply time?\n- Should we have a global and a local registry for validators?\n    --\u003e Would be cool to register things for the schema only\n    --\u003e Would be cool if this was extendable \n- Do we need all off in order to combine multiple schemas?\n- Allow custom errors\n- Spec inheritance of params\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnxt-insurance%2Fnxt_schema","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fnxt-insurance%2Fnxt_schema","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnxt-insurance%2Fnxt_schema/lists"}