# EasyTalk
[](https://badge.fury.io/rb/easy_talk)
[](https://github.com/sergiobayona/easy_talk/actions/workflows/dev-build.yml)
[](https://codecov.io/gh/sergiobayona/easy_talk)
[](https://opensource.org/licenses/MIT)
[](https://www.ruby-lang.org)
[](https://rubygems.org/gems/easy_talk)
[](https://rubydoc.info/gems/easy_talk)
[](https://github.com/sergiobayona/easy_talk)
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:
```ruby
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. See [RubyLLM Integration](#rubyllm-integration).
EasyTalk is for teams who want their data contracts to be **correct, reusable, and boring** (the good kind of boring).
---
## Table of Contents
- [Installation](#installation)
- [Quick start](#quick-start)
- [Property constraints](#property-constraints)
- [Core concepts](#core-concepts)
- [Required vs optional vs nullable](#required-vs-optional-vs-nullable-dont-get-tricked)
- [Nested models](#nested-models-and-automatic-instantiation)
- [Tuple arrays](#tuple-arrays-fixed-position-types)
- [Composition (AnyOf / OneOf / AllOf)](#composition-anyof--oneof--allof)
- [Validations](#validations)
- [Automatic validations](#automatic-validations-default)
- [Per-model validation control](#per-model-validation-control)
- [Per-property validation control](#per-property-validation-control)
- [Validation adapters](#validation-adapters)
- [Error formatting](#error-formatting)
- [Schema-only mode](#schema-only-mode)
- [RubyLLM Integration](#rubyllm-integration)
- [Configuration highlights](#configuration-highlights)
- [Advanced topics](#advanced-topics)
- [JSON Schema drafts, `$id`, and `$ref`](#json-schema-drafts-id-and-ref)
- [Additional properties with types](#additional-properties-with-types)
- [Object-level constraints](#object-level-constraints)
- [Custom type builders](#custom-type-builders)
- [Known limitations](#known-limitations)
- [Contributing](#contributing)
- [License](#license)
---
## Installation
### Requirements
- Ruby **3.2+**
Add to your Gemfile:
```ruby
gem "easy_talk"
```
Then:
```bash
bundle install
```
---
## Quick start
| EasyTalk Model |
Generated JSON Schema |
|
```ruby
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
```
|
```json
{
"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"]
}
```
|
```ruby
User.json_schema # => Ruby Hash (JSON Schema)
user = User.new(name: "A") # invalid: min_length is 2
user.valid? # => false
user.errors # => ActiveModel::Errors
```
---
## 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:
```ruby
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:
```ruby
EasyTalk.configure do |config|
config.nilable_is_optional = true
end
```
---
### Nested models (and automatic instantiation)
Define nested objects as separate classes, then reference them:
```ruby
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:
```ruby
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):
| EasyTalk Model |
Generated JSON Schema |
|
```ruby
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]
)
```
|
```json
{
"properties": {
"coordinates": {
"type": "array",
"items": [
{ "type": "number" },
{ "type": "number" }
]
}
}
}
```
|
**Mixed-type tuples:**
```ruby
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:**
```ruby
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:**
```ruby
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)
```ruby
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:
```ruby
EasyTalk.configure do |config|
config.auto_validations = true
end
```
Disable globally:
```ruby
EasyTalk.configure do |config|
config.auto_validations = false
end
```
When auto validations are off, you can still write validations manually:
```ruby
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
```ruby
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
```ruby
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:
```ruby
EasyTalk.configure do |config|
config.validation_adapter = :active_model # default
# config.validation_adapter = :none # disable validation generation
end
```
---
## Error formatting
Instance helpers:
```ruby
user.validation_errors_flat
user.validation_errors_json_pointer
user.validation_errors_rfc7807
user.validation_errors_jsonapi
```
Format directly:
```ruby
EasyTalk::ErrorFormatter.format(user.errors, format: :rfc7807, title: "User Validation Failed")
```
Global defaults:
```ruby
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:
```ruby
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.
---
## RubyLLM Integration
EasyTalk integrates seamlessly with [RubyLLM](https://github.com/crmne/ruby_llm) for structured outputs and tool definitions.
### Structured Outputs
Use any EasyTalk model with RubyLLM's `with_schema` to get structured JSON responses:
```ruby
class Recipe
include EasyTalk::Model
define_schema do
description "A cooking recipe"
property :name, String, description: "Name of the dish"
property :ingredients, T::Array[String], description: "List of ingredients"
property :prep_time_minutes, Integer, description: "Preparation time in minutes"
end
end
chat = RubyLLM.chat.with_schema(Recipe)
response = chat.ask("Give me a simple pasta recipe")
# RubyLLM returns parsed JSON - instantiate with EasyTalk model
recipe = Recipe.new(response.content)
recipe.name # => "Spaghetti Aglio e Olio"
recipe.ingredients # => ["spaghetti", "garlic", "olive oil", ...]
```
### Tools
Create LLM tools by inheriting from `RubyLLM::Tool` and including `EasyTalk::Model`:
```ruby
class Weather < RubyLLM::Tool
include EasyTalk::Model
define_schema do
description 'Gets current weather for a location'
property :latitude, String, description: 'Latitude (e.g., 52.5200)'
property :longitude, String, description: 'Longitude (e.g., 13.4050)'
end
def execute(latitude:, longitude:)
# Fetch weather data from API...
{ temperature: 22, conditions: "sunny" }
end
end
chat = RubyLLM.chat.with_tool(Weather)
response = chat.ask("What's the weather in Berlin?")
```
This pattern gives you:
- Full access to `RubyLLM::Tool` features (`halt`, `call`, etc.)
- EasyTalk's schema DSL for parameter definitions
- Automatic JSON Schema generation for the LLM
---
## Configuration highlights
```ruby
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](https://rubydoc.info/gems/easy_talk).
### 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:
```ruby
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 Model |
Generated JSON Schema |
|
```ruby
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
```
|
```json
{
"properties": {
"address": {
"$ref": "https://example.com/schemas/address"
}
},
"$defs": {
"Address": {
"$id": "https://example.com/schemas/address",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" }
}
}
}
}
```
|
**Explicit schema IDs:**
```ruby
class Address
include EasyTalk::Model
define_schema do
schema_id 'https://example.com/schemas/address'
property :street, String
end
end
```
**Per-property ref control:**
```ruby
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:
```ruby
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:**
| EasyTalk Model |
Generated JSON Schema |
|
```ruby
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
```
|
```json
{
"properties": {
"id": { "type": "integer" }
},
"additionalProperties": {
"type": "integer",
"minimum": 0,
"maximum": 100
}
}
```
|
**Nested models as additional properties:**
```ruby
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:
```ruby
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:**
```ruby
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:**
```ruby
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:
```ruby
EasyTalk.configure do |config|
config.register_type(Money, MoneySchemaBuilder)
end
# Or directly:
EasyTalk::Builders::Registry.register(Money, MoneySchemaBuilder)
```
See the [Custom Type Builders documentation](https://rubydoc.info/gems/easy_talk/EasyTalk/Builders/Registry) 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