Skip to content

DynamicSchema is a lightweight and simple yet powerful gem that enables flexible semantic schema definitions for constructing and validating complex configurations and other similar payloads.

License

Notifications You must be signed in to change notification settings

EndlessInternational/dynamic_schema

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

51 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

DynamicSchema

The DynamicSchema gem provides an elegant and expressive way to define domain-specific language (DSL) schemas, making it effortless to build and validate complex Ruby Hash constructs.

This is particularly useful when dealing with intricate configuration or interfacing with external APIs, where data structures need to adhere to specific formats and validations. By allowing default values, type constraints, nested schemas, and transformations, DynamicSchema ensures that your data structures are both robust and flexible.

You can trivially define a custom schema:

openai_request_schema = DynamicSchema.define do
  model           String, default: 'gpt-4o'
  max_tokens      Integer, default: 1024
  temperature     Float, in: 0..1

  message         arguments: [ :role ], as: :messages, array: true do
    role          Symbol, in: [ :system, :user, :assistant ]
    content       array: true do
      type        Symbol, default: :text
      text        String
    end
  end
end

And then repeatedly use that schema to elegantly build a schema-conformant Hash:

request = openai_request_schema.build {
  message :system do
    content text: "You are a helpful assistant that talks like a pirate."
  end
  message :user do
    content text: ARGV[0] || "say hello!"
  end
}

You can find a full OpenAI request example in the /examples folder of this repository.


Table of Contents


Installation

Add this line to your application's Gemfile:

gem 'dynamicschema'

And then execute:

bundle install

Or install it yourself as:

gem install dynamicschema

Usage

Requiring the Gem

To start using the dynamic_schema gem, simply require it in your Ruby file:

require 'dynamic_schema'

Defining Schemas with DynamicSchema

DynamicSchema lets you define a DSL made of values, objects, and options, then reuse that DSL to build and validate Ruby Hashes.

You start by constructing a DynamicSchema::Builder. You can do this by calling:

  • DynamicSchema.define { … }
  • DynamicSchema::Builder.new.define { … }

In both cases, you pass a block that declares the schema values and objects with their options.

schema = DynamicSchema.define do
  # values with an optional default
  api_key        String
  model          String,  default: 'gpt-4o'

  # object with its own values
  chat_options do
    max_tokens   Integer, default: 1024
    temperature  Float,   in: 0..1
  end
end

You can then:

# build without validation
built = schema.build do
  api_key 'secret'
  chat_options do
    temperature 0.7
  end
end

# build with validation (raises on first error)
built_validated = schema.build! do
  api_key 'secret'
  chat_options do
    temperature 0.7
  end
end

# validate an existing Hash (no building)
errors = schema.validate( { api_key: 'secret', chat_options: { temperature: 0.7 } } )
valid  = schema.valid?( { api_key: 'secret', chat_options: { temperature: 0.7 } } )

Inheritance

You can extend an existing schema using the inherit: option. Pass a Proc that describes the parent schema—typically from a class that includes DynamicSchema::Definable via its schema method.

class BaseSettings
  include DynamicSchema::Definable
  schema do
    api_key String, required: true
  end
end

# extend the base schema with additional fields
builder = DynamicSchema.define( inherit: BaseSettings.schema ) do
  region Symbol, in: %i[us eu apac]
end

settings = builder.build! do
  api_key 'secret'
  region  :us
end

You can call build, build!, validate, validate!, and valid? on the builder as needed.


Struct

In addition to building plain Ruby Hash values, DynamicSchema can generate lightweight Ruby classes from a schema. A DynamicSchema::Struct exposes readers and writers for the fields you define, and transparently wraps nested objects so that you can access them with dot-style accessors rather than deep hash indexing.

You create a struct class by passing the same schema shape you would give to a Builder. The schema can be provided as:

  • a Proc that defines the schema
  • a DynamicSchema::Builder
  • a compiled Hash (advanced)
require 'dynamic_schema'

# simple struct with typed fields
Person = DynamicSchema::Struct.define do
  full_name String
  age       Integer
end

person = Person.build( full_name: 'Sam Lee', age: '42' )
person.age          # => 42 (coerced using the same converters as Builder)
person.full_name = 'Samira Lee'
person.to_h         # => { full_name: 'Samira Lee', age: 42 }

# nested object with its own accessors
Company = DynamicSchema::Struct.define do
  employee do
    full_name String
    years_of_service Integer
  end
end

acme = Company.build( employee: { full_name: 'Alex', years_of_service: 5 } )
acme.employee.full_name        # => 'Alex'
acme.employee.years_of_service # => 5

# array of nested objects
Order = DynamicSchema::Struct.define do
  items array: true do
    name  String
    price Integer
  end
end

order = Order.build( items: [ { name: 'Desk', price: 100 }, { name: 'Chair', price: 50 } ] )
order.items.map { | i | i.name }   # => [ 'Desk', 'Chair' ]

# referencing another struct class
OrderItem = DynamicSchema::Struct.define do
  name     String
  quantity Integer
end

OrderCollection = DynamicSchema::Struct.define do
  order_number String
  line_items   OrderItem, array: true
end

collection = OrderCollection.new( {
  order_number: 'A-100',
  line_items: [ { name: 'Desk', quantity: 1 }, { name: 'Chair', quantity: 2 } ]
} )
collection.line_items[ 0 ].name     # => 'Desk'
collection.line_items[ 1 ].quantity # => 2
  • defining
    • DynamicSchema::Struct.define takes a block that looks exactly like a Builder schema.
    • Use array: true to expose arrays of nested structs.
    • You may reference another struct class as a value type; arrays of that type expose nested accessors for each element.
  • building
    • StructClass.build( attributes ) constructs an instance and (optionally) coerces typed scalar fields using the same converters as the Builder.
    • StructClass.build! additionally validates the instance just like builder.build!.
  • accessing
    • Use standard Ruby readers/writers: instance.attribute, instance.attribute = value.
    • #to_h returns a deep Hash of the current values (nested structs become hashes).

You can also create a struct class from a builder or a compiled hash if you already have a schema elsewhere:

builder = DynamicSchema.define do
  name String
end

NameStruct = DynamicSchema::Struct.new( builder )
NameStruct.build( name: 'Taylor' ).name  # => 'Taylor'
  • validation
    • struct instances include the same validation helpers as hashes built via a builder.
    • StructClass.build! validates immediately and raises on the first error.
    • instances respond to #validate!, #validate, and #valid? using the compiled schema.

Values

A value is a basic building block of your schema. Values represent individual settings, options or API parameters that you can define with specific types, defaults, and other options.

When defining a value, you provide the name as though you were calling a Ruby method, with arguments that include an optional type (which can be a Class, Module or an Array of these) as well as a Hash of options, all of which are optional:

name {type}, default: {value}, required: {true|false}, array: {true|false}, as: {name}, in: {Array|Range}

example:

require 'dynamic_schema'

# define a schema structure with values
schema = DynamicSchema.define do
  api_key
  version String, default: '1.0'
end

# build the schema and set values
result = schema.build! do
  api_key 'your-api-key'
end

# access the schema values
puts result[:api_key]     # => "your-api-key"
puts result[:version]     # => "1.0"
  • defining
    • api_key defines a value named api_key. Any type can be used to assign the value.
    • version String, default: '1.0' defines a value with a default.
  • building
    • schema.build! accepts both a Hash and a block where you can set the values.
    • Inside the block, api_key 'your-api-key' sets the value of api_key.
  • accessing
    • result[:api_key] retrieves the value of api_key.
    • If a value has a default and you don't set it, the default value will be included in resulting hash.

Objects

A schema may be organized hierarchically, by creating collections of related values and even other collections. These collections are called objects.

An object is defined in a similar manner to a value. Simply provide the name as though calling a Ruby method, with a Hash of options and a block which encloses the child values and objects:

name arguments: [ {argument} ], default: {value}, required: {true|false}, array: {true|false}, as: {name} do
  # child values and objects can be defined here
end

Notice an object does not accept a type as it is always of type Object.

example:

require 'dynamic_schema'

schema = DynamicSchema.define do
  api_key String
  chat_options do
    model String, default: 'claude-3'
    max_tokens Integer, default: 1024
    temperature Float, default: 0.5, in: 0..1
    stream [ TrueClass, FalseClass ]
  end
end

result = schema.build! do
  api_key 'your-api-key'
  chat_options do
    temperature 0.8
    stream true
  end
end

# Accessing values
puts result[:api_key]                     # => "your-api-key"
puts result[:chat_options][:model]        # => "claude-3"
puts result[:chat_options][:temperature]  # => 0.8
puts result[:chat_options][:stream]       # => true
  • defining
    • chat_options do ... end defines an object named chat_options.
    • Inside the object you can define values that belong to that object.
  • building
    • In the build block, you can set values for values within objects by nesting blocks.
    • chat_options do ... end allows you to set values inside the chat_options object.
  • accessing
    • You access values by chaining the keys: result[:chat_options][:model].

Types

An object is always of type Object. A value can have no type or it can be of one or more types. You specify the value type by providing an instance of a Class when defining the value. If you want to specify multiple types simply provide an array of types.

example:

require 'dynamic_schema'

schema = DynamicSchema.define do
  typeless_value
  symbol_value      Symbol
  boolean_value     [ TrueClass, FalseClass ]
end

result = schema.build! do
  typeless_value    Struct.new(:name).new(name: 'Kristoph')
  symbol_value      "something"
  boolean_value     true
end

puts result[:typeless_value].name             # => "Kristoph"
puts result[:symbol_value]                    # => :something
puts result[:boolean_value]                   # => true
  • defining
    • typeless_value defines a value that has no type and will accept an assignment of any type
    • symbol_value defines a value that accepts symbols or types that can be coerced into symbols, such as strings (see Type Coercion)
    • boolean_value defines a value that can be either true or false

Custom Types

You can use any Ruby class as a value type, not just the built-in types. When a custom class is specified as the type, DynamicSchema will validate that values are instances of that class. You can also configure custom class instances using blocks:

require 'dynamic_schema'

class Customer
  attr_accessor :name, :email
end

schema = DynamicSchema.define do
  customer Customer
end

# auto-instantiate and configure with a block
result = schema.build! do
  customer do
    name 'Alice'
    email 'alice@example.com'
  end
end

result[:customer].name   # => 'Alice'
result[:customer].email  # => 'alice@example.com'

# or provide an existing instance
existing = Customer.new
existing.name = 'Bob'

result = schema.build! do
  customer existing do
    email 'bob@example.com'
  end
end

result[:customer].name   # => 'Bob'
result[:customer].email  # => 'bob@example.com'

When using a block with a custom type:

  • If no instance is provided, DynamicSchema will call YourClass.new to create one
  • Inside the block, method calls are translated to setter calls on the instance
  • You can provide an existing instance and still use a block to configure it further

This is particularly useful when integrating with DynamicSchema::Struct or other custom classes that need to be configured within a schema.

Multiple Types

You can specify multiple types for a value by providing an array of types. The value must match one of the listed types.

schema = DynamicSchema.define do
  enabled [ TrueClass, FalseClass ]
  identifier [ String, Integer ]
end

result = schema.build! do
  enabled true
  identifier 12345
end

result[ :enabled ]     # => true
result[ :identifier ]  # => 12345

Validation ensures the value matches one of the specified types:

schema.valid?( { enabled: true } )        # => true
schema.valid?( { enabled: 'yes' } )       # => false (string not in types)
schema.valid?( { identifier: 'abc' } )    # => true
schema.valid?( { identifier: 123 } )      # => true

Multiple Types with Nested Schema

When you combine multiple types with a block, you create a field that can be either a nested object (defined by the block) or one of the scalar types. The decision is made at runtime:

  • Hash values (or blocks in Builder) are processed using the nested schema
  • Non-hash values are validated against the scalar types in the array

This is useful for APIs where a field might be either a structured object or a simple value like a boolean.

Builder Example

schema = DynamicSchema.define do
  # 'data' can be either a nested object OR true/false
  data [ Object, TrueClass, FalseClass ] do
    name String
    value Integer
  end
end

# Using as a nested object (with block)
result = schema.build! do
  data do
    name 'example'
    value 42
  end
end
result[ :data ][ :name ]   # => 'example'

# Using as a nested object (with hash)
result = schema.build! do
  data( { name: 'from hash', value: 100 } )
end
result[ :data ][ :name ]  # => 'from hash'

# Using as a boolean
result = schema.build! do
  data true
end
result[ :data ]  # => true

Struct Example

Settings = DynamicSchema::Struct.define do
  config [ Object, TrueClass, FalseClass ] do
    host String
    port Integer
  end
end

# With nested object - accessed via dot notation
settings = Settings.build( config: { host: 'localhost', port: 8080 } )
settings.config.host  # => 'localhost'
settings.config.port  # => 8080

# With boolean - returned as-is
settings = Settings.build( config: false )
settings.config  # => false

Arrays with Multiple Types

Combine with array: true to create arrays where each element can be either a nested object or a scalar:

schema = DynamicSchema.define do
  items [ Object, TrueClass, FalseClass ], array: true do
    name String
  end
end

result = schema.build! do
  items( { name: 'first' } )
  items true
  items( { name: 'second' } )
  items false
end

result[ :items ]  # => [ { name: 'first' }, true, { name: 'second' }, false ]

Validation

Validation checks that hash values conform to the nested schema and non-hash values match one of the scalar types:

schema = DynamicSchema.define do
  data [ Object, TrueClass, FalseClass ] do
    value Integer
  end
end

schema.valid?( { data: { value: 42 } } )  # => true (valid nested object)
schema.valid?( { data: true } )           # => true (valid boolean)
schema.valid?( { data: 'invalid' } )      # => false (string not in types)

Options

Both values and objects can be customized through options. The options for both values and objects include default, required, as and array. In addition values support the in criteria option while objects support the arguments option.

:default Option

The :default option allows you to specify a default value that will be used if no value is provided during build.

example:

schema = DynamicSchema.define do
  api_version String, default: 'v1'
  timeout Integer, default: 30
end

result = schema.build!
puts result[:api_version]  # => "v1"
puts result[:timeout]      # => 30

:required Option

The :required option ensures that a value must be provided when building the schema. If a required value is missing when using build!, validate, or validate!, a DynamicSchema::RequiredOptionError will be raised.

example:

schema = DynamicSchema.define do
  api_key String, required: true
  timeout Integer, default: 30
end

# This will raise DynamicSchema::RequiredOptionError
result = schema.build!

# This is valid
result = schema.build! do
  api_key 'my-secret-key'
end

:array Option

The :array option wraps the value or object in an array in the resulting Hash, even if only one value is provided. This is particularly useful when dealing with APIs that expect array inputs.

example:

schema = DynamicSchema.define do
  tags String, array: true
  message array: true do
    text String
    type String, default: 'plain'
  end
end

result = schema.build! do
  tags 'important'
  message do
    text 'Hello world'
  end
end

puts result[:tags]      # => ["important"]
puts result[:message]   # => [{ text: "Hello world", type: "plain" }]

:as Option

The :as option allows you to use a different name in the DSL than what appears in the final Hash. This is particularly useful when interfacing with APIs that have specific key requirements.

example:

schema = DynamicSchema.define do
  content_type String, as: "Content-Type", default: "application/json"
  api_key String, as: "Authorization"
end

result = schema.build! do
  api_key 'Bearer abc123'
end

puts result["Content-Type"]    # => "application/json"
puts result["Authorization"]   # => "Bearer abc123"

:in Option

The :in option provides validation for values, ensuring they fall within a specified Range or are included in an Array of allowed values. This option is only available for values.

example:

schema = DynamicSchema.define do
  temperature Float, in: 0..1
  status String, in: ['pending', 'processing', 'completed']
end

# Valid
result = schema.build! do
  temperature 0.7
  status 'pending'
end

# Will raise validation error - temperature out of range
result = schema.build! do
  temperature 1.5
  status 'pending'
end

# Will raise validation error - invalid status
result = schema.build! do
  temperature 0.7
  status 'invalid'
end

:arguments Option

The :arguments option allows objects to accept arguments when building. Any arguments provided must appear when the object is built (and so are implicitly 'required').

If an argument is provided, the same argument appears in the attributes hash, or in the object block, the assignment in the block will take priority, followed by the attributes assigned and finally the argument.

example:

schema = DynamicSchema.define do
  message arguments: [ :role ], as: :messages, array: true do
    role Symbol, required: true, in: [ :system, :user, :assistant ]
    content String
  end
end

result = schema.build! do
  message :system do
    content "You are a helpful assistant."
  end
  message :user do
    content "Hello!"
  end
end

:normalize Option

The :normalize option allows you to transform values immediately upon assignment. It accepts a lambda or Proc that receives the value and returns the transformed result. When used with arrays, the normalizer is called for each individual item, not the array itself.

example:

schema = DynamicSchema.define do
  name String, normalize: ->( v ) { v.strip.downcase }
  tags String, array: true, normalize: ->( v ) { v.upcase }
  score Integer, normalize: ->( v ) { v.clamp( 0, 100 ) }
end

result = schema.build! do
  name '  Alice  '
  tags [ 'important', 'urgent' ]
  score 150
end

puts result[:name]   # => "alice"
puts result[:tags]   # => ["IMPORTANT", "URGENT"]
puts result[:score]  # => 100

The normalizer is applied after type coercion, so you receive the properly typed value:

schema = DynamicSchema.define do
  count Integer, normalize: ->( v ) { v * 2 }
end

result = schema.build! do
  count '5'  # coerced to Integer first, then normalized
end

puts result[:count]  # => 10

You can also use normalize with objects to transform the entire nested structure:

CustomUser = Struct.new( :name, :email )

schema = DynamicSchema.define do
  user normalize: ->( v ) { CustomUser.new( v.to_h[:name], v.to_h[:email] ) } do
    name String
    email String
  end
end

result = schema.build! do
  user do
    name 'Alice'
    email 'alice@example.com'
  end
end

result[:user].class  # => CustomUser
result[:user].name   # => "Alice"

The normalize option works with Struct as well:

Person = DynamicSchema::Struct.define do
  name String, normalize: ->( v ) { v.strip.capitalize }
  tags String, array: true, normalize: ->( v ) { v.downcase }
end

person = Person.build( name: '  alice  ', tags: [ 'ADMIN', 'USER' ] )
person.name  # => "Alice"
person.tags  # => ["admin", "user"]

Note that any exceptions raised within a normalize lambda are not captured and will propagate to the caller:

schema = DynamicSchema.define do
  value Integer, normalize: ->( v ) { raise "invalid value" if v < 0; v }
end

# This will raise RuntimeError: "invalid value"
schema.build! { value -1 }

Class Schemas

DynamicSchema provides a number of modules you can include into your own classes to simplify their definition and construction.

Definable

The Definable module, when included in a class, will add the schema and the builder class methods.

By calling schema with a block you can define a schema for that specific class. You may also retrieve the defined schema by calling 'schema' (with or without a block). The 'schema' method may be called repeatedly to build up a schema with each call adding to the existing schema (replacing values and objects of the same name if they appear in subsequent calls).

The schema method will integrate with a class hierarchy. By including Definable in a base class you can call schema to define a schema for that base class and then in subsequent derived classes to augment it for those classes.

The builder method will return a memoized builder of the schema defined by calls to the schema method which can be used to build and validate schema conformant hashes.

class Setting
  include DynamicSchema::Definable
  schema do
    name String
  end
end

class DatabaseSetting < Setting
  schema do
    database do
      host  String
      port  String
      name  String
    end
  end

  def initialize( attributes = {} )
    # validate the attributes
    self.class.builder.validate!( attributes )
    # retain them for future access
    @attributes = attributes&.dup
  end

end

Buildable

The Buildable module can be included in a class, in addition to Definable, to facilitate building that class using a schema assisted builder pattern. The Buildable module adds build! and build methods to the class which can be used to build that class, with and without validation respectively.

These methods accept both a Hash with attributes that follow the schema, as well as a block that can be used to build the class instance. The attributes and block can be used simultaneously.

Important Note that Buildable requires a class method builder (which Definable provides) and an initializer that accepts a Hash of attributes.

class Setting
  include DynamicSchema::Definable
  include DynamicSchema::Buildable
  schema do
    name String
  end
end

class DatabaseSetting < Setting
  schema do
    database do
      adapter   Symbol
      host      String
      port      String
      name      String
    end
  end

  def initialize( attributes = {} )
    # validate the attributes
    self.class.builder.validate!( attributes )
    # retain them for the future
    @attributes = attributes&.dup
  end
end

database_settings = DatabaseSetting.build! name: 'settings.database' do
  database adapter: :pg do
    host     "localhost"
    port     "127.0.0.1"
    name     "mydb"
  end
end

Validation

DynamicSchema provides three different methods for validating Hash structures against your defined schema: validate!, validate, and valid?.

These methods allow you to verify that your data conforms to your schema requirements, including type constraints, required fields, and value ranges.

Validation Rules

When validating, DynamicSchema checks:

  1. Required Fields: Any value or object marked as required: true is present.
  2. Type Constraints: Any values match their specified types or can be coerced to the specified type.
  3. Value Ranges: Any values fall within their specified :in constraints.
  4. Objects: Any objects are recursively validated.
  5. Arrays: Any validation rules are applied to each element when array: true

validate!

The validate! method performs strict validation and raises an exception when it encounters the first validation error.

example:

schema = DynamicSchema.define do
  api_key String, required: true
  temperature Float, in: 0..1
end

# this will raise DynamicSchema::RequiredOptionError
schema.validate!( { temperature: 0.5 } )

# this will raise DynamicSchema::IncompatibleTypeError
schema.validate!( {
  api_key: ["not-a-string"],
  temperature: 0.5
} )

# this will raise DynamicSchema::InOptionError
schema.validate!( {
  api_key: "abc123",
  temperature: 1.5
} )

# this is valid and will not raise any errors
schema.validate!( {
  api_key: 123,
  temperature: 0.5
} )

validate

The validate method performs validation but instead of raising exceptions, it collects and returns an array of all validation errors encountered.

example:

schema = DynamicSchema.define do
  api_key String, required: true
  model String, in: ['gpt-3.5-turbo', 'gpt-4']
  temperature Float, in: 0..1
end

errors = schema.validate({
  model: 'invalid-model',
  temperature: 1.5,
  api_key: ["invalid-type"]  # Array cannot be coerced to String
})

# errors will contain:
# - IncompatibleTypeError for api_key being an Array
# - InOptionError for invalid model
# - InOptionError for temperature out of range

valid?

The valid? method provides a simple boolean check of whether a Hash conforms to the schema.

example:

schema = DynamicSchema.define do
  name String, required: true
  age Integer, in: 0..120
  id String  # Will accept both strings and numbers due to coercion
end

# Returns false
schema.valid?({
  name: ["Not a string"],  # Array cannot be coerced to String
  age: 150                 # Outside allowed range
})

# Returns true
schema.valid?({
  name: "John",
  age: 30,
  id: 12345               # Numeric value can be coerced to String
})

Error Types

DynamicSchema provides specific error types for different validation failures:

  • DynamicSchema::RequiredOptionError: Raised when a required field is missing
  • DynamicSchema::IncompatibleTypeError: Raised when a value's type doesn't match the schema and cannot be coerced
  • DynamicSchema::InOptionError: Raised when a value falls outside its specified range/set
  • ArgumentError: Raised when the provided values structure isn't a Hash

Each error includes helpful context about the validation failure, including the path to the failing field and the specific constraint that wasn't met.


Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/EndlessInternational/dynamic_schema.

License

The gem is available as open source under the terms of the MIT License.

About

DynamicSchema is a lightweight and simple yet powerful gem that enables flexible semantic schema definitions for constructing and validating complex configurations and other similar payloads.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages