Skip to content

sergiobayona/easy_talk

Repository files navigation

EasyTalk

Gem Version Ruby codecov License: MIT Ruby Downloads Documentation GitHub stars

Ruby library for defining structured data contracts that generate JSON Schema and (optionally) runtime validations from the same definition.

Think “Pydantic-style ergonomics” for Ruby, with first-class JSON Schema output.


Why EasyTalk?

You can hand-write JSON Schema, then hand-write validations, then hand-write error responses… and eventually you’ll ship a bug where those three disagree.

EasyTalk makes the schema definition the single source of truth, so you can:

  • Define once, use everywhere
    One Ruby DSL gives you:

    • json_schema for docs, OpenAPI, LLM tools, and external validators
    • valid? / errors (when using EasyTalk::Model) for runtime validation
  • Stop arguing with JSON Schema’s verbosity
    Express constraints in Ruby where you already live:

    property :email, String, format: "email"
    property :age, Integer, minimum: 18
    property :tags, T::Array[String], min_items: 1
  • Use a richer type system than "string/integer/object" EasyTalk supports Sorbet-style types and composition:

    • T.nilable(Type) for nullable fields
    • T::Array[Type] for typed arrays
    • T::Tuple[Type1, Type2, ...] for fixed-position typed arrays
    • T::Boolean
    • T::AnyOf, T::OneOf, T::AllOf for schema composition
  • Get validations for free (when you want them)
    With auto_validations enabled (default), schema constraints generate ActiveModel validations—including nested models, even inside arrays.

  • Make API errors consistent
    Format validation errors as:

    • flat lists
    • JSON Pointer
    • RFC 7807 problem details
    • JSON:API error objects
  • LLM tool/function schemas without a second schema layer
    Use the same contract to generate JSON Schema for function/tool calling.

EasyTalk is for teams who want their data contracts to be correct, reusable, and boring (the good kind of boring).


Installation

Requirements

  • Ruby 3.2+

Add to your Gemfile:

gem "easy_talk"

Then:

bundle install

Quick start

require "easy_talk"

class User
  include EasyTalk::Model

  define_schema do
    title "User"
    description "A user of the system"

    property :id, String
    property :name, String, min_length: 2
    property :email, String, format: "email"
    property :age, Integer, minimum: 18
  end
end

User.json_schema   # => Ruby Hash (JSON Schema)
user = User.new(name: "A")  # invalid: min_length is 2
user.valid?        # => false
user.errors        # => ActiveModel::Errors

Generated JSON Schema:

{
  "type": "object",
  "title": "User",
  "description": "A user of the system",
  "properties": {
    "id": { "type": "string" },
    "name": { "type": "string", "minLength": 2 },
    "email": { "type": "string", "format": "email" },
    "age": { "type": "integer", "minimum": 18 }
  },
  "required": ["id", "name", "email", "age"]
}

Property constraints

Constraint Applies to Example
min_length / max_length String property :name, String, min_length: 2, max_length: 50
minimum / maximum Integer, Float property :age, Integer, minimum: 18, maximum: 120
format String property :email, String, format: "email"
pattern String property :zip, String, pattern: '^\d{5}$'
enum Any property :status, String, enum: ["active", "inactive"]
min_items / max_items Array, Tuple property :tags, T::Array[String], min_items: 1
unique_items Array, Tuple property :ids, T::Array[Integer], unique_items: true
additional_items Tuple property :coords, T::Tuple[Float, Float], additional_items: false
optional Any property :nickname, String, optional: true
default Any property :role, String, default: "user"
description Any property :name, String, description: "Full name"
title Any property :name, String, title: "User Name"

Object-level constraints (applied in define_schema block):

  • min_properties / max_properties - Minimum/maximum number of properties
  • pattern_properties - Schema for properties matching regex patterns
  • dependent_required - Conditional property requirements

When auto_validations is enabled (default), these constraints automatically generate corresponding ActiveModel validations.


Core concepts

Required vs optional vs nullable (don't get tricked)

JSON Schema distinguishes:

  • Optional: property may be omitted (not in required)
  • Nullable: property may be null (type includes "null")

EasyTalk mirrors that precisely:

class Profile
  include EasyTalk::Model

  define_schema do
    # required, not nullable
    property :name, String

    # required, nullable (must exist, may be null)
    property :age, T.nilable(Integer)

    # optional, not nullable (may be omitted, but cannot be null if present)
    property :nickname, String, optional: true

    # optional + nullable (may be omitted OR null)
    property :bio, T.nilable(String), optional: true
    # or, equivalently:
    nullable_optional_property :website, String
  end
end

By default, T.nilable(Type) makes a field nullable but still required.
If you want “nilable implies optional” behavior globally:

EasyTalk.configure do |config|
  config.nilable_is_optional = true
end

Nested models (and automatic instantiation)

Define nested objects as separate classes, then reference them:

class Address
  include EasyTalk::Model

  define_schema do
    property :street, String
    property :city, String
  end
end

class User
  include EasyTalk::Model

  define_schema do
    property :name, String
    property :address, Address
  end
end

user = User.new(
  name: "John",
  address: { street: "123 Main St", city: "Boston" } # Hash becomes Address automatically
)

user.address.class  # => Address

Nested models inside arrays work too:

class Order
  include EasyTalk::Model

  define_schema do
    property :line_items, T::Array[Address], min_items: 1
  end
end

Tuple arrays (fixed-position types)

Use T::Tuple for arrays where each position has a specific type (e.g., coordinates, CSV rows, database records):

class GeoLocation
  include EasyTalk::Model

  define_schema do
    property :name, String
    # Fixed: [latitude, longitude]
    property :coordinates, T::Tuple[Float, Float]
  end
end

location = GeoLocation.new(
  name: 'Office',
  coordinates: [40.7128, -74.0060]
)

Generated JSON Schema:

{
  "properties": {
    "coordinates": {
      "type": "array",
      "items": [
        { "type": "number" },
        { "type": "number" }
      ]
    }
  }
}

Mixed-type tuples:

class DataRow
  include EasyTalk::Model

  define_schema do
    # Fixed: [name, age, active]
    property :row, T::Tuple[String, Integer, T::Boolean]
  end
end

Controlling extra items:

define_schema do
  # Reject extra items (strict tuple)
  property :rgb, T::Tuple[Integer, Integer, Integer], additional_items: false

  # Allow extra items of specific type
  property :header_values, T::Tuple[String], additional_items: Integer

  # Allow any extra items (default)
  property :flexible, T::Tuple[String, Integer]
end

Tuple validation:

model = GeoLocation.new(coordinates: [40.7, "invalid"])
model.valid?  # => false
model.errors[:coordinates]
# => ["item at index 1 must be a Float"]

Composition (AnyOf / OneOf / AllOf)

class ProductA
  include EasyTalk::Model
  define_schema do
    property :sku, String
    property :weight, Float
  end
end

class ProductB
  include EasyTalk::Model
  define_schema do
    property :sku, String
    property :color, String
  end
end

class Cart
  include EasyTalk::Model

  define_schema do
    property :items, T::Array[T::AnyOf[ProductA, ProductB]]
  end
end

Validations

Automatic validations (default)

EasyTalk can generate ActiveModel validations from constraints:

EasyTalk.configure do |config|
  config.auto_validations = true
end

Disable globally:

EasyTalk.configure do |config|
  config.auto_validations = false
end

When auto validations are off, you can still write validations manually:

class User
  include EasyTalk::Model

  validates :name, presence: true, length: { minimum: 2 }

  define_schema do
    property :name, String, min_length: 2
  end
end

Per-model validation control

class LegacyModel
  include EasyTalk::Model

  define_schema(validations: false) do
    property :data, String, min_length: 1  # no validation generated
  end
end

Per-property validation control

class User
  include EasyTalk::Model

  define_schema do
    property :name, String, min_length: 2
    property :legacy_field, String, validate: false
  end
end

Validation adapters

EasyTalk uses a pluggable adapter system:

EasyTalk.configure do |config|
  config.validation_adapter = :active_model  # default
  # config.validation_adapter = :none        # disable validation generation
end

Error formatting

Instance helpers:

user.validation_errors_flat
user.validation_errors_json_pointer
user.validation_errors_rfc7807
user.validation_errors_jsonapi

Format directly:

EasyTalk::ErrorFormatter.format(user.errors, format: :rfc7807, title: "User Validation Failed")

Global defaults:

EasyTalk.configure do |config|
  config.default_error_format = :rfc7807
  config.error_type_base_uri = "https://api.example.com/errors"
  config.include_error_codes = true
end

Schema-only mode

If you want schema generation and attribute accessors without ActiveModel validation:

class ApiContract
  include EasyTalk::Schema

  define_schema do
    title "API Contract"
    property :name, String, min_length: 2
    property :age, Integer, minimum: 0
  end
end

ApiContract.json_schema
contract = ApiContract.new(name: "Test", age: 25)

# No validations available:
# contract.valid?  # => NoMethodError

Use this for documentation, OpenAPI generation, or when validation happens elsewhere.


Configuration highlights

EasyTalk.configure do |config|
  # Schema behavior
  config.default_additional_properties = false
  config.nilable_is_optional = false
  config.schema_version = :none
  config.schema_id = nil
  config.use_refs = false
  config.base_schema_uri = nil                 # Base URI for auto-generating $id
  config.auto_generate_ids = false             # Auto-generate $id from base_schema_uri
  config.prefer_external_refs = false          # Use external URI in $ref when available
  config.property_naming_strategy = :identity  # :snake_case, :camel_case, :pascal_case

  # Validations
  config.auto_validations = true
  config.validation_adapter = :active_model

  # Error formatting
  config.default_error_format = :flat          # :flat, :json_pointer, :rfc7807, :jsonapi
  config.error_type_base_uri = "about:blank"
  config.include_error_codes = true
end

Advanced topics

For more detailed documentation, see the full API reference on RubyDoc.

JSON Schema drafts, $id, and $ref

EasyTalk can emit $schema for multiple drafts (Draft-04 through 2020-12), supports $id, and can use $ref/$defs for reusable definitions:

EasyTalk.configure do |config|
  config.schema_version = :draft202012
  config.schema_id = "https://example.com/schemas/user.json"
  config.use_refs = true  # Use $ref/$defs for nested models
end

External schema references

Use external URIs in $ref for modular, reusable schemas:

EasyTalk.configure do |config|
  config.use_refs = true
  config.prefer_external_refs = true
  config.base_schema_uri = 'https://example.com/schemas'
  config.auto_generate_ids = true
end

class Address
  include EasyTalk::Model

  define_schema do
    property :street, String
    property :city, String
  end
end

class Customer
  include EasyTalk::Model

  define_schema do
    property :name, String
    property :address, Address
  end
end

Customer.json_schema
# =>
# {
#   "properties": {
#     "address": { "$ref": "https://example.com/schemas/address" }
#   },
#   "$defs": {
#     "Address": {
#       "$id": "https://example.com/schemas/address",
#       "properties": { "street": {...}, "city": {...} }
#     }
#   }
# }

Explicit schema IDs:

class Address
  include EasyTalk::Model

  define_schema do
    schema_id 'https://example.com/schemas/address'
    property :street, String
  end
end

Per-property ref control:

class Customer
  include EasyTalk::Model

  define_schema do
    property :address, Address, ref: false  # Inline instead of ref
    property :billing, Address              # Uses ref (global setting)
  end
end

Additional properties with types

Beyond boolean values, additional_properties now supports type constraints for dynamic properties:

class Config
  include EasyTalk::Model

  define_schema do
    property :name, String

    # Allow any string-typed additional properties
    additional_properties String
  end
end

config = Config.new(name: 'app')
config.label = 'Production'  # Dynamic property
config.as_json
# => { 'name' => 'app', 'label' => 'Production' }

With constraints:

class StrictConfig
  include EasyTalk::Model

  define_schema do
    property :id, Integer
    # Integer values between 0 and 100 only
    additional_properties Integer, minimum: 0, maximum: 100
  end
end

StrictConfig.json_schema
# =>
# {
#   "properties": { "id": { "type": "integer" } },
#   "additionalProperties": {
#     "type": "integer",
#     "minimum": 0,
#     "maximum": 100
#   }
# }

Nested models as additional properties:

class Person
  include EasyTalk::Model

  define_schema do
    property :name, String
    additional_properties Address  # All additional properties must be Address objects
  end
end

Object-level constraints

Apply schema-wide constraints to limit or validate object structure:

class StrictObject
  include EasyTalk::Model

  define_schema do
    property :required1, String
    property :required2, String
    property :optional1, String, optional: true
    property :optional2, String, optional: true

    # Require at least 2 properties
    min_properties 2
    # Allow at most 3 properties
    max_properties 3
  end
end

obj = StrictObject.new(required1: 'a')
obj.valid?  # => false (only 1 property, needs at least 2)

Pattern properties:

class DynamicConfig
  include EasyTalk::Model

  define_schema do
    property :name, String

    # Properties matching /^env_/ must be strings
    pattern_properties(
      '^env_' => { type: 'string' }
    )
  end
end

Dependent required:

class ShippingInfo
  include EasyTalk::Model

  define_schema do
    property :name, String
    property :credit_card, String, optional: true
    property :billing_address, String, optional: true

    # If credit_card is present, billing_address is required
    dependent_required(
      'credit_card' => ['billing_address']
    )
  end
end

Custom type builders

Register custom types with their own schema builders:

EasyTalk.configure do |config|
  config.register_type(Money, MoneySchemaBuilder)
end

# Or directly:
EasyTalk::Builders::Registry.register(Money, MoneySchemaBuilder)

See the Custom Type Builders documentation for details on creating builders.


Known limitations

EasyTalk aims to produce broadly compatible JSON Schema, but:

  • Some draft-specific keywords/features may require manual schema tweaks
  • Custom formats are limited (extend via custom builders when needed)
  • Extremely complex composition can outgrow “auto validations” and may need manual validations or external schema validators

Contributing

  • Run bin/setup
  • Run specs: bundle exec rake spec
  • Run lint: bundle exec rubocop

Bug reports and PRs welcome.


License

MIT

About

Ruby library for defining, generating and validating JSON Schema

Topics

Resources

License

Stars

Watchers

Forks

Contributors 11

Languages