RubyLLM::Schema Is Now Schematist: A JSON Schema DSL for Ruby with Full Draft 2020-12 Coverage

RubyLLM::Schema Is Now Schematist: A JSON Schema DSL for Ruby with Full Draft 2020-12 Coverage

I want to make Ruby the best language to work with LLMs. Part of that is a great JSON Schema DSL.

Schematist is a general purpose JSON Schema DSL that emits Draft 2020-12 schemas. Describe an API payload, a config file, a contract between two services, or the structured output you want back from a model. Trapping that inside another gem’s namespace was a disservice to anyone looking for a great JSON Schema DSL, so it got its own name.

gem 'schematist'

It Emits Actual JSON Schema

This is the breaking change.

to_json_schema used to return this:

{ name: "PersonSchema", description: nil, schema: { type: "object", ... }, strict: true }

That’s not a JSON Schema. It’s OpenAI’s response_format envelope, with the actual schema buried one level down under a symbol key. Every consumer that wasn’t OpenAI had to dig it out, and anyone who wanted to hand the result to a validator had to know which part was real.

Now you get the document:

class Invoice < Schematist::Schema
  title "Invoice"
  description "A billing document"

  string :id, pattern: "^inv_", title: "Invoice ID"
  number :total, greater_than: 0, description: "Amount due"
  string :currency, const: "EUR"
  string :status, enum: %w[draft sent paid], default: "draft"
end

Invoice.new.to_json_schema
# => {
#   "$schema" => "https://json-schema.org/draft/2020-12/schema",
#   "title" => "Invoice",
#   "description" => "A billing document",
#   "type" => "object",
#   "properties" => {
#     "id" => { "type" => "string", "pattern" => "^inv_", "title" => "Invoice ID" },
#     "total" => { "type" => "number", "description" => "Amount due", "exclusiveMinimum" => 0 },
#     ...
#   },
#   "required" => ["id", "total", "currency", "status"],
#   "additionalProperties" => false
# }

String keys, $schema declared, no provider keys. Use it with JSON.generate unchanged and any Draft 2020-12 validator will take it.

strict went with it. It’s an OpenAI request flag, not a JSON Schema keyword, and a schema library has no business knowing OpenAI exists. Set it where you build the request.

Full Draft 2020-12 Coverage

The old gem covered the basics: types, enum, required, string and numeric bounds, nested objects and arrays, $defs and $ref, if/then/else. Schematist covers the whole vocabulary.

Composition. allOf, oneOf, and not join anyOf:

one_of :method do
  object { string :card_number }
  object { string :iban }
end

all_of :account, unevaluated_properties: false do
  object { string :id }
  object { string :status }
end

none_of :state do
  string enum: ["deleted"]
end

unevaluated_properties is the one that makes allOf usable in practice. additionalProperties can’t see across composition branches; unevaluatedProperties can.

Object keys. Constrain how many properties an object has, what its keys look like, and what the values behind a key pattern must be:

object :metadata, min_properties: 1, max_properties: 10 do
  keys { string pattern: "^[a-z_]+$" }     # propertyNames
  keys_matching(/^x-/) { string }          # patternProperties
end

Arrays. uniqueItems, fixed-length tuples via prefixItems, and contains with its bounds:

array :tags, of: :string, unique: true

tuple :period do
  string format: "date"
  string format: "date"
end

array :scores do
  integer
  contains(min: 1) { integer minimum: 10 }   # at least one score of 10 or more
end

Annotations. title, description, default, examples, deprecated, read_only, write_only. Short ones read well as keyword arguments; longer ones read better in the block, where they annotate the enclosing schema:

object :account do
  title "Account"
  description "Billing account metadata used for invoices."
  examples [{ id: "acct_123", status: "active" }]

  string :id
  string :status
end

Encoded content. For strings that carry something else inside them:

string :payload, content_encoding: "base64", content_media_type: "application/json" do
  content_schema do
    object { string :name }
  end
end

Core keywords. $id, $anchor, $comment, $dynamicAnchor, $dynamicRef, $vocabulary, at the root or on any subschema. They’re passed straight through. Resolving a dynamic reference is the validator’s job, not ours.

Also new: const on every primitive, and greater_than / less_than for exclusiveMinimum / exclusiveMaximum. I picked the Ruby-sounding names over the JSON Schema ones on purpose. You’re writing Ruby.

Values That Aren’t Known Until Render Time

You define a schema class once, at boot. The allowed values often aren’t known until a request comes in.

Any value can be a proc now, resolved when the document is rendered:

class RoleSchema < Schematist::Schema
  string :role, enum: -> { @account.roles.pluck(:name) }

  def initialize(account:)
    super()
    @account = account
  end
end

RoleSchema.new(account: account).to_json_schema

A zero-argument proc is evaluated in the instance’s context, so it can read instance variables. A proc that takes one argument gets the schema instance instead. One class, a different document per instance.

Escape Hatches

Covering the spec isn’t the same as guessing everything you’ll want to put in a document, so there are two ways out.

JSON Schema allows true and false in place of a schema object. true accepts anything, false accepts nothing:

any_of :value do
  any_schema
  string
end

And raw drops a fragment in as-is, for a vendor extension or anything else the DSL has no opinion about:

raw :vendor, { "type" => "object", "x-vendor" => true }

A Schema Doesn’t Have To Be an Object

Most schemas describe an object, so that’s the default. But JSON Schema doesn’t care. A schema can be an array, a union, a string, or a pointer somewhere else, and the root of a document is just a schema like any other.

So: a type with a name declares a property. Without a name, it declares what the schema itself is.

class Tags < Schematist::Schema
  array of: :string, unique: true       # the whole schema is an array
end

class Id < Schematist::Schema
  one_of do                             # the whole schema is a choice
    string
    integer
  end
end

class Person < Schematist::Schema
  raw({ "$ref" => "https://example.com/person.json" })
end

It works inside define too, so a reusable definition can be a string with a pattern or a shared enum, not just an object:

define :status do
  string enum: %w[draft sent paid]
end

A conditional branch is a schema too, so it can ask for a nested object instead of a flat list of fields:

given kind: "business" do
  requires :vat_id

  object :tax_details do
    string :vat_number
  end
end

No Runtime Dependencies

Schematist depends on nothing.

Migrating

gem 'schematist'                         # was: gem 'ruby_llm-schema'

class Person < Schematist::Schema        # was: RubyLLM::Schema
end

Errors moved up a level: Schematist::ValidationError, not RubyLLM::Schema::ValidationError. Schematist::Helpers replaces RubyLLM::Helpers.

If you were reaching into [:schema] to get at the document, stop. to_json_schema returns it directly now, with string keys. If you need the provider wrapper, build it where you send the request:

{ name: "Invoice", schema: Invoice.new.to_json_schema, strict: true }

There’s a final ruby_llm-schema 1.0.0 that depends on Schematist and aliases the old constants, so RubyLLM::Schema keeps resolving while you move. It warns on load and it’s the last release of that name.

RubyLLM 2.0 will depend on Schematist, so structured output will get a lot more powerful.

Use It

bundle add schematist

Schematist was always a JSON Schema DSL. Now it has the name to match.

Newsletter