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.
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_schemafor docs, OpenAPI, LLM tools, and external validatorsvalid?/errors(when usingEasyTalk::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 fieldsT::Array[Type]for typed arraysT::Tuple[Type1, Type2, ...]for fixed-position typed arraysT::BooleanT::AnyOf,T::OneOf,T::AllOffor schema composition
-
Get validations for free (when you want them)
Withauto_validationsenabled (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).
- Ruby 3.2+
Add to your Gemfile:
gem "easy_talk"Then:
bundle installrequire "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::ErrorsGenerated 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"]
}| 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 propertiespattern_properties- Schema for properties matching regex patternsdependent_required- Conditional property requirements
When auto_validations is enabled (default), these constraints automatically generate corresponding ActiveModel validations.
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
endBy 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
endDefine 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 # => AddressNested models inside arrays work too:
class Order
include EasyTalk::Model
define_schema do
property :line_items, T::Array[Address], min_items: 1
end
endUse 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
endControlling 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]
endTuple validation:
model = GeoLocation.new(coordinates: [40.7, "invalid"])
model.valid? # => false
model.errors[:coordinates]
# => ["item at index 1 must be a Float"]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
endEasyTalk can generate ActiveModel validations from constraints:
EasyTalk.configure do |config|
config.auto_validations = true
endDisable globally:
EasyTalk.configure do |config|
config.auto_validations = false
endWhen 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
endclass LegacyModel
include EasyTalk::Model
define_schema(validations: false) do
property :data, String, min_length: 1 # no validation generated
end
endclass User
include EasyTalk::Model
define_schema do
property :name, String, min_length: 2
property :legacy_field, String, validate: false
end
endEasyTalk uses a pluggable adapter system:
EasyTalk.configure do |config|
config.validation_adapter = :active_model # default
# config.validation_adapter = :none # disable validation generation
endInstance helpers:
user.validation_errors_flat
user.validation_errors_json_pointer
user.validation_errors_rfc7807
user.validation_errors_jsonapiFormat 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
endIf 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? # => NoMethodErrorUse this for documentation, OpenAPI generation, or when validation happens elsewhere.
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
endFor more detailed documentation, see the full API reference on RubyDoc.
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
endUse 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
endPer-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
endBeyond 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
endApply 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
endDependent 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
endRegister 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.
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
- Run
bin/setup - Run specs:
bundle exec rake spec - Run lint:
bundle exec rubocop
Bug reports and PRs welcome.
MIT