schematrix

Generate Ruby code and RBS signatures from JSON Schema definitions.

schematrix is a Ruby gem and CLI tool that reads JSON Schema files and produces idiomatic Ruby source code. Point it at a schema, choose a generator, and get ready-to-use Ruby classes and type signatures written into your project.

Seventeen per-class output formats share one compiler: plain Ruby classes, Ruby classes with inline Sorbet signatures, Sorbet RBI files, RBS signature files, and each of those four notations again as a builder DSL, a partial class family, and enum classes, plus a standalone DSL parser. The DSL modules define instances of the schema through plain method calls, and a project mixes them into its own classes; running one builds the document as a Hash, and parsing one reconstructs the same document statically.

Qualifying string and integer enums can generate named member classes with the four *_enums generators, or render as literal unions in RBS. See Enums.


Requirements


Installation

Add schematrix to your Gemfile:

gem 'schematrix', '~> 0.1'

Then run:

bundle install

Or install it directly:

gem install schematrix

Quick Start

Given this JSON Schema (person.schema.json):

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Person",
  "type": "object",
  "properties": {
    "firstName": { "type": "string" },
    "lastName":  { "type": "string" },
    "age":       { "type": "integer" }
  },
  "required": ["firstName", "lastName"]
}

Run:

schematrix -g plain_ruby -g auxiliary_files -m MyApp -o app/generated person.schema.json

This produces app/generated/person.rb plus the shared app/generated/coercion_helper.rb module (see generators):

# DO NOT EDIT MANUALLY
# This is an autogenerated file for the Person class.
# Please instead update this file by running `schematrix`.

module MyApp
  class Person
    ORIGINAL_KEYS = {
      "firstName" => :first_name,
      "lastName" => :last_name,
      "age" => :age
    }.freeze

    def initialize(first_name:, last_name:, age: nil)
      @first_name = first_name
      @last_name = last_name
      @age = age

      @_extra = {}
    end

    attr_accessor :first_name, :last_name, :age

    def [](key)
      @_extra[key]
    end

    def []=(key, value)
      @_extra[key] = value
    end

    def self.from_h(hash)
      instance =
        new(
          first_name: hash["firstName"],
          last_name: hash["lastName"],
          age: hash["age"]
        )

      hash.each do |key, value|
        next if ORIGINAL_KEYS.key?(key)

        instance[key] = value
      end
      instance
    end

    def to_h
      result = {}
      result["firstName"] = @first_name unless @first_name.nil?
      result["lastName"] = @last_name unless @last_name.nil?
      result["age"] = @age unless @age.nil?
      @_extra.each { |key, value| result[key] = value }
      result
    end
  end
end

Every generated class carries ORIGINAL_KEYS, the map from the schema’s property names to the generated accessor symbols, plus a from_h/to_h pair that loads and dumps documents under those original names: nested object schemas load through their own from_h, unknown keys ride the catch-all ([]/[]=) with their original String keys, and absent keys keep their constructor defaults. See the generated code guide.

Schema documents in YAML work the same way; JSON is a subset of YAML and both load.


CLI Reference

Usage: schematrix [OPTIONS] INPUT [INPUT...]

Generate code matching a JSON Schema

Options:
      --closed-shapes      Treat absent additionalProperties as false: closed
                           classes with no catch-all, instead of the spec
                           default
      --enums string       Enum slot types: classes or unions (default depends
                           on generators)
  -g, --generators list    Output generators to use, e.g.: plain_ruby,
                           auxiliary_files
  -h, --help               Print usage
      --immutable          Generate data classes as frozen value objects:
                           readers, a with copier, and value equality; data
                           generators only
  -m, --module string      Module where the output code will be placed
  -o, --output string      Directory where the output will be written
      --schema-dir string  Directory where remote schema URIs resolve to local
                           files; may be given multiple times, defaults to
                           vendor/schemas
  -v, --verbose            Each additional occurrence lowers logger level,
                           -vvv for the minimum

Examples:
  Produce plain Ruby code for the given schema
    $ schematrix -g plain_ruby -m MyModule -o /output/dir my-json-schema.json

The options list above mirrors schematrix --help. The full option reference, including output directory pairing and exit behavior, lives in the command line manual.

Example usages

Generate plain Ruby classes for a single schema:

schematrix -g plain_ruby -g auxiliary_files -m MyApp -o app/generated schema.json

Generate a standalone DSL parser, with its support files included:

schematrix -g dsl_parser -m MyApp -o out input.json

The generated parser needs Prism 1.4 or later, but no Schematrix runtime. See The instance DSL for the accepted grammar and reader extensions.

Process multiple schemas at once:

schematrix -g rbs -m Contracts user.schema.json order.schema.json

Or even:

schematrix -g plain_ruby -g auxiliary_files -m Contracts *.schema.json

You might specify different output directories for different generators like so:

schematrix -g plain_ruby -o generated -g rbi -o rbi -g auxiliary_files -o generated *.schema.json

Resolving remote schemas

schematrix never fetches anything over the network. When a schema references another schema through an http(s) URI, that URI must resolve to a local file through a schema directory. By default the tool searches vendor/schemas in the current directory; pass --schema-dir to point somewhere else:

schematrix -g plain_ruby -m MyApp --schema-dir local/schemas schema.json

The option may be given multiple times; the directories are searched in order, and explicit directories replace the default rather than adding to it.

The directory mirrors the URI structure: host, then path. The URI

https://json-schema.org/draft/2020-12/meta/core

resolves to the first existing file among

vendor/schemas/json-schema.org/draft/2020-12/meta/core
vendor/schemas/json-schema.org/draft/2020-12/meta/core.json
vendor/schemas/json-schema.org/draft/2020-12/meta/core.yaml
vendor/schemas/json-schema.org/draft/2020-12/meta/core.yml

Directories given more than once are searched in order, and the first matching file wins. A remote URI with no matching local file is an error listing every candidate path. The 2020-12 JSON Schema meta files are vendored in this repository under vendor/schemas, so schemas referencing https://json-schema.org/draft/2020-12/... resolve out of the box when running from the repository root.


Generators

The data, DSL, and partial generators write object schema files. The four enum generators write qualifying enum schema files:

Name Writes
plain_ruby Mutable Ruby classes, no annotations
sorbet_ruby Mutable Ruby classes with inline Sorbet sig blocks
rbi Sorbet signature files: signatures only, empty bodies
rbs RBS signature files
dsl_plain_ruby Instance DSL builders, no annotations
dsl_sorbet_ruby Instance DSL builders with inline Sorbet sig blocks
dsl_rbi Sorbet signature files for the instance DSL builders
dsl_rbs RBS signature files for the instance DSL builders
dsl_parser Prism parsers for DSL source, without executing the source
partial_plain_ruby Partial classes, every property wrapped in the Maybe type
partial_sorbet_ruby Partial classes with inline Sorbet sig blocks
partial_rbi Sorbet signature files for the partial classes
partial_rbs RBS signature files for the partial classes
plain_ruby_enums Enum classes with frozen members, no runtime dependency
sorbet_ruby_enums T::Enum classes with inline Sorbet signatures
rbi_enums Sorbet enum declarations
rbs_enums RBS declarations for the plain enum API

See the generator guide for sample output and the type mapping tables per notation. Class names, module nesting, and file paths derive from the schema as described in Names and files. The instance DSL has its own page: The instance DSL.


Library usage

The CLI wraps one method. Require the gem, build generator instances, and call Schematrix.generate:

require "schematrix"
require "schematrix/generators"

Schematrix.generate(
  generators: [
    Schematrix::Generators::PlainRuby.new("generated", "MyApp"),
    Schematrix::Generators::Rbs.new("sig", "MyApp"),
    Schematrix::Generators::AuxiliaryFiles.new("generated", "MyApp"),
  ],
  input_files: ["person.schema.json"],
)

Collisions (two classes on one file) are detected before the first write, and the compile raises instead of producing partial output. See the library manual for the parameters, the error types, and logging. The gem exports the Sorbet signatures of this API in rbi/, which tapioca picks up.


How It Works

flowchart LR
    A["JSON Schema files"] --> B["Compiler"]
    B -- "Visits every schema reachable from the inputs and builds an internal representation of each object it finds" --> C["Generators\nlib/schematrix/generators/*"]
    C -- "Transform each object into code and write one file per object into the output directory" --> D["Output files\ne.g. generated/person.rb"]

The core components are:


Documentation

The complete manual lives in docs/:

The API reference is generated with RDoc: run bundle exec rake rdoc and open doc/index.html.


Development

Clone the repository:

git clone https://github.com/cinzatech/schematrix.git
cd schematrix
bundle install

Run the CLI from the project root:

bundle exec exe/schematrix -g plain_ruby -m Test -o /tmp/generated examples/basic.schema.json

Run the test suite:

bundle exec rake test

Format and lint:

bundle exec stree check lib test exe rbi Rakefile
bundle exec rubocop
SRB_SKIP_GEM_RBIS=1 bundle exec srb tc

Contributing

Please, feel free to send Pull Requests. Machine-generated code is acceptable as long as it meets our standards for code quality and there’s at least one human behind who understands the code being sent. We believe in code as theory building, so we want contributions that strengthen the shared mental model, not weaken it.


License

schematrix is released under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later).