Page title
Section title
Generating Ruby from JSON Typedef schemas
JSON Type Definition, aka RFC 8927, is an easy-to-learn, standardized way to define a schema for JSON data. You can use JSON Typedef to portably validate data across programming languages, create dummy data, generate code, and more.
This article is about how you can use JSON Typedef to generate Ruby code from
schemas. If you’re interested in generating code in other languages, see this
article on jtd-codegen
. The rest of this article focuses
on using jtd-codegen
with Ruby in particular.
Generating Ruby with jtd-codegen
As a prerequisite, you need to first install jtd-codegen
. Installation
instructions are available here.
You can generate Ruby with jtd-codegen
using the --ruby-out
option, whose
value must be a directory that jtd-codegen
can generate code into. You’ll also
need to pass --ruby-module
, indicating the Ruby module jtd-codegen
should
generate its classes into.
For example, if you have this schema in schemas/user.jtd.json
:
{
"properties": {
"id": { "type": "string" },
"createdAt": { "type": "timestamp" },
"karma": { "type": "int32" },
"isAdmin": { "type": "boolean" }
}
}
Then you can generate Ruby code into the src
directory by running:
jtd-codegen schemas/user.jtd.json --ruby-out src --ruby-module User
Which will output something like:
📝 Writing Ruby code to: src
📦 Generated Ruby code.
📦 Root schema converted into type: User
And you should see code along these lines in src/user.rb
:
# Code generated by jtd-codegen for Ruby v0.1.0
require 'json'
require 'time'
module User
class User
attr_accessor :created_at
attr_accessor :id
attr_accessor :is_admin
attr_accessor :karma
def self.from_json_data(data)
out = User.new
out.created_at = User::from_json_data(DateTime, data["createdAt"])
out.id = User::from_json_data(String, data["id"])
out.is_admin = User::from_json_data(TrueClass, data["isAdmin"])
out.karma = User::from_json_data(Integer, data["karma"])
out
end
def to_json_data
data = {}
data["createdAt"] = User::to_json_data(created_at)
data["id"] = User::to_json_data(id)
data["isAdmin"] = User::to_json_data(is_admin)
data["karma"] = User::to_json_data(karma)
data
end
end
private
def self.from_json_data(type, data)
if data.nil? || [Object, TrueClass, Integer, Float, String].include?(type)
data
elsif type == DateTime
DateTime.rfc3339(data)
elsif type.is_a?(Array)
data.map { |elem| from_json_data(type.first, elem) }
elsif type.is_a?(Hash)
data.transform_values { |elem| from_json_data(type.values.first, elem) }
else
type.from_json_data(data)
end
end
def self.to_json_data(data)
if data.nil? || [TrueClass, FalseClass, Integer, Float, String].include?(data.class)
data
elsif data.is_a?(DateTime)
data.rfc3339
elsif data.is_a?(Array)
data.map { |elem| to_json_data(elem) }
elsif data.is_a?(Hash)
data.transform_values { |elem| to_json_data(elem) }
else
data.to_json_data
end
end
end
Note: at the time of writing, generated code is not always formatted in a
pretty way. If you require pretty-formatted code, it’s recommended that you use
a code formatter on jtd-codegen
-generated code.
Generating Ruby Signatures with jtd-codegen
Ruby 3 introduces support for rbs
, a
standardized way to add static type checking to Ruby programs. jtd-codegen
supports generating .rbs
files for all generated Ruby code.
Just as Ruby code generation accepts --ruby-out
and --ruby-module
, RBS code
generation accepts --ruby-sig-out
and --ruby-sig-module
. You can generate
both at once like so:
jtd-codegen schemas/user.jtd.json \
--ruby-out src --ruby-module User \
--ruby-sig-out lib --ruby-sig-module User
At which point, you can use a tool like
steep
to verify that your code is
interacting with the jtd-codegen
-generated classes correctly.
Using generated Ruby code
jtd-codegen
will always output code into a .rb
file inside the directory you
specify with --ruby-out
; the name of the file is derived from the value of
--ruby-module
. In the previous example, we outputted code into src/user.rb
,
so we can import it like so:
require_relative 'user'
The generated Ruby code does not presume a particular JSON implementation. You
can use jtd-codegen
-generated types with the standard library’s
JSON
module, or
you can use alternatives like oj
.
The generated code is JSON-library independent because every generated class has
a from_json_data
and to_json_data
method. from_json_data
takes in
already-parsed JSON data, such as data returned from JSON.parse
, and returns
an instance of the type. to_json_data
does the reverse, constructing data that
you can pass to JSON.generate
.
Do not directly pass instances of jtd-codegen
-generated types to JSON
libraries. You will get the wrong JSON result if you do this. Always pass
through from_json_data
or to_json_data
first.
Here’s an example of you’d use the User
class we imported above:
require 'json'
# To read in JSON, assuming you know the JSON is valid, do something like:
input_json = '...'
user = User::User.from_json_data(JSON.parse(input_json))
# To write out JSON, do something like:
output_json = JSON.generate(user.to_json_data)
In the example above, we directly use from_json_data
on parsed JSON data. This
will only work correctly if the JSON input is valid. Otherwise, you may
encounter runtime exceptions, and the types of the class’s attributes may be
different from those indicated on their RBS annotations. If you do run into
runtime exceptions, these exceptions will be Ruby-specific and low-level.
To prevent this from happening, you should first validate the JSON input using a
JTD validation implementation, such as the jtd
gem. What you would do is:
- Parse the input using your preferred JSON backend.
- Validate that the parsed JSON is valid against the schema you generated your types from, using a JTD validation implementation. If there are validation errors, you can return those, because JTD validation errors are standardized and platform-independent.
- If the input is valid, then construct an instance of your generated type
using its
from_json_data
method. Pass in the JSON you just validated tofrom_json_data
.
This solution lets you produce portable validation errors and lets you be more deliberate about what inputs you do and don’t accept.
Customizing Ruby output
Ruby code generation supports the following metadata properties shared across
all languages supported by jtd-codegen
:
-
description
customizes the documentation comment to put on a type or property in generated code. For example, this schema:{ "metadata": { "description": "A user in our system" }, "properties": { "name": { "metadata": { "description": "The user's name" }, "type": "string" }, "isAdmin": { "metadata": { "description": "Whether the user is an admin" }, "type": "boolean" } } }
Generates into:
# A user in our system class Docuser # Whether the user is an admin attr_accessor :is_admin # The user's name attr_accessor :name def self.from_json_data(data) out = Docuser.new out.is_admin = User::from_json_data(TrueClass, data["isAdmin"]) out.name = User::from_json_data(String, data["name"]) out end def to_json_data data = {} data["isAdmin"] = User::to_json_data(is_admin) data["name"] = User::to_json_data(name) data end end
-
enumDescription
is likedescription
, but for the members of anenum
. The keys ofenumDescription
should correspond to the values in the schema’senum
, and the values should be descriptions for those values. For example, this schema:{ "metadata": { "enumDescription": { "PENDING": "The job is waiting to be processed.", "IN_PROGRESS": "The job is being processed.", "DONE": "The job has been processed." } }, "enum": ["PENDING", "IN_PROGRESS", "DONE"] }
Generates into:
class Status attr_accessor :value def initialize(value) self.value = value end private_class_method :new # The job has been processed. DONE = new("DONE") # The job is being processed. IN_PROGRESS = new("IN_PROGRESS") # The job is waiting to be processed. PENDING = new("PENDING") def self.from_json_data(data) { "DONE" => DONE, "IN_PROGRESS" => IN_PROGRESS, "PENDING" => PENDING, }[data] end def to_json_data value end end
Additionally, Ruby code generation supports the following Ruby-specific options:
-
rubyType
overrides the type thatjtd-codegen
should generate.jtd-codegen
will not generate any code for schemas withrubyType
, and instead use the value ofrubyType
as-is.It is your responsibility to ensure that the value of
rubyType
is valid code.jtd-codegen
will not attempt to validate its value.For example, this schema:
{ "properties": { "name": { "type": "string" }, "isAdmin": { "metadata": { "rubyType": "MyCustomType" }, "type": "boolean" } } }
Generates into:
class OverrideDemo attr_accessor :is_admin attr_accessor :name def self.from_json_data(data) out = OverrideDemo.new out.is_admin = Example::from_json_data(MyCustomType, data["isAdmin"]) out.name = Example::from_json_data(String, data["name"]) out end def to_json_data data = {} data["isAdmin"] = Example::to_json_data(is_admin) data["name"] = Example::to_json_data(name) data end end
With a corresponding type signature (if you’re also generating
.rbs
files):class OverrideDemo attr_accessor is_admin: MyCustomType attr_accessor name: String def self.from_json_data: (untyped) -> OverrideDemo def to_json_data: () -> untyped end
Generated Ruby code
This section details the sort of Ruby code that jtd-codegen
will generate.
Code generated from “Empty” schemas
“Empty” schemas will be converted into a
Ruby untyped
if you’re using RBS:
{}
Generates into:
# .rb file
class Empty
attr_accessor :value
def self.from_json_data(data)
out = Empty.new
out.value = Example.from_json_data(Object, data)
out
end
def to_json_data
Example.to_json_data(value)
end
end
# .rbs file
class Empty
attr_accessor value: untyped
def self.from_json_data: (untyped) -> Empty
def to_json_data: () -> untyped
end
Note: jtd-codegen
had to generate a custom type alias here, which is why the
code has a bit of extra stuff. If you use “empty”, “type”, “ref”, “elements”, or
“values” schemas at the top level of a schema, jtd-codegen
has to emit type
aliases in Ruby. In real-world schemas, this doesn’t happen very often.
Code generated from “Ref” schemas
“Ref” schemas will be converted into a reference to the definition being referred to:
{
"definitions": {
"example": { "type": "string" }
},
"ref": "example"
}
Generates into:
# .rb file
class Ref
attr_accessor :value
def self.from_json_data(data)
out = Ref.new
out.value = Example.from_json_data(Example, data)
out
end
def to_json_data
Example.to_json_data(value)
end
end
class Example
attr_accessor :value
def self.from_json_data(data)
out = Example.new
out.value = Example.from_json_data(String, data)
out
end
def to_json_data
Example.to_json_data(value)
end
end
# .rbs file
class Ref
attr_accessor value: Example
def self.from_json_data: (untyped) -> Ref
def to_json_data: () -> untyped
end
class Example
attr_accessor value: String
def self.from_json_data: (untyped) -> Example
def to_json_data: () -> untyped
end
Note: jtd-codegen
had to generate a custom type alias here, which is why the
code has a bit of extra stuff. If you use “empty”, “type”, “ref”, “elements”, or
“values” schemas at the top level of a schema, jtd-codegen
has to emit type
aliases in Ruby. In real-world schemas, this doesn’t happen very often.
Code generated from “Type” schemas
“Type” schemas will be converted into the following RBS types:
JSON Typedef type | Ruby type |
---|---|
boolean |
bool |
string |
String |
timestamp |
DateTime |
float32 |
Float |
float64 |
Float |
int8 |
Integer |
uint8 |
Integer |
int16 |
Integer |
uint16 |
Integer |
int32 |
Integer |
uint32 |
Integer |
For example,
{
"properties": {
"boolean": { "type": "boolean" },
"string": { "type": "string" },
"timestamp": { "type": "timestamp" },
"float32": { "type": "float32" },
"float64": { "type": "float64" },
"int8": { "type": "int8" },
"uint8": { "type": "uint8" },
"int16": { "type": "int16" },
"uint16": { "type": "uint16" },
"int32": { "type": "int32" },
"uint32": { "type": "uint32" }
}
}
Generates into:
# .rb file
class Type
attr_accessor :boolean
attr_accessor :float32
attr_accessor :float64
attr_accessor :int16
attr_accessor :int32
attr_accessor :int8
attr_accessor :string
attr_accessor :timestamp
attr_accessor :uint16
attr_accessor :uint32
attr_accessor :uint8
def self.from_json_data(data)
out = Type.new
out.boolean = Example::from_json_data(TrueClass, data["boolean"])
out.float32 = Example::from_json_data(Float, data["float32"])
out.float64 = Example::from_json_data(Float, data["float64"])
out.int16 = Example::from_json_data(Integer, data["int16"])
out.int32 = Example::from_json_data(Integer, data["int32"])
out.int8 = Example::from_json_data(Integer, data["int8"])
out.string = Example::from_json_data(String, data["string"])
out.timestamp = Example::from_json_data(DateTime, data["timestamp"])
out.uint16 = Example::from_json_data(Integer, data["uint16"])
out.uint32 = Example::from_json_data(Integer, data["uint32"])
out.uint8 = Example::from_json_data(Integer, data["uint8"])
out
end
def to_json_data
data = {}
data["boolean"] = Example::to_json_data(boolean)
data["float32"] = Example::to_json_data(float32)
data["float64"] = Example::to_json_data(float64)
data["int16"] = Example::to_json_data(int16)
data["int32"] = Example::to_json_data(int32)
data["int8"] = Example::to_json_data(int8)
data["string"] = Example::to_json_data(string)
data["timestamp"] = Example::to_json_data(timestamp)
data["uint16"] = Example::to_json_data(uint16)
data["uint32"] = Example::to_json_data(uint32)
data["uint8"] = Example::to_json_data(uint8)
data
end
end
# .rbs file
class Type
attr_accessor boolean: bool
attr_accessor float32: Float
attr_accessor float64: Float
attr_accessor int16: Integer
attr_accessor int32: Integer
attr_accessor int8: Integer
attr_accessor string: String
attr_accessor timestamp: DateTime
attr_accessor uint16: Integer
attr_accessor uint32: Integer
attr_accessor uint8: Integer
def self.from_json_data: (untyped) -> Type
def to_json_data: () -> untyped
end
Code generated from “Enum” schemas
“Enum” schemas will be converted into a Ruby class where each element of the enum will be an instance of the class, and with a private constructor (so you can’t accidentally create new instances of the class):
{
"enum": ["PENDING", "IN_PROGRESS", "DONE"]
}
Generates into:
# .rb file
class Enum
attr_accessor :value
def initialize(value)
self.value = value
end
private_class_method :new
DONE = new("DONE")
IN_PROGRESS = new("IN_PROGRESS")
PENDING = new("PENDING")
def self.from_json_data(data)
{
"DONE" => DONE,
"IN_PROGRESS" => IN_PROGRESS,
"PENDING" => PENDING,
}[data]
end
def to_json_data
value
end
end
# .rbs file
class Enum
attr_accessor value: String
DONE: Enum
IN_PROGRESS: Enum
PENDING: Enum
def self.from_json_data: (untyped) -> Enum
def to_json_data: () -> untyped
end
Code generated from “Elements” schemas
“Elements” schemas will be converted
into a Ruby Array
of T
(in RBS syntax: Array[T]
), where T
is the type of
the elements of the array:
{
"elements": {
"type": "string"
}
}
Generates into:
# .rb file
class Elements
attr_accessor :value
def self.from_json_data(data)
out = Elements.new
out.value = Example.from_json_data(Array[String], data)
out
end
def to_json_data
Example.to_json_data(value)
end
end
# .rbs file
class Elements
attr_accessor value: Array[String]
def self.from_json_data: (untyped) -> Elements
def to_json_data: () -> untyped
end
Note: jtd-codegen
had to generate a custom type alias here, which is why the
code has a bit of extra stuff. If you use “empty”, “type”, “ref”, “elements”, or
“values” schemas at the top level of a schema, jtd-codegen
has to emit type
aliases in Ruby. In real-world schemas, this doesn’t happen very often.
Code generated from “Properties” schemas
“Properties” schemas will be
converted into a plain old Ruby class. Optional properties will be marked with
?
in the RBS, and will be omitted from JSON if set to nil
.
{
"properties": {
"name": { "type": "string" },
"isAdmin": { "type": "boolean" }
},
"optionalProperties": {
"middleName": { "type": "string" }
},
"additionalProperties": true
}
Generates into:
# .rb file
class Properties
attr_accessor :is_admin
attr_accessor :name
attr_accessor :middle_name
def self.from_json_data(data)
out = Properties.new
out.is_admin = Example::from_json_data(TrueClass, data["isAdmin"])
out.name = Example::from_json_data(String, data["name"])
out.middle_name = Example::from_json_data(String, data["middleName"])
out
end
def to_json_data
data = {}
data["isAdmin"] = Example::to_json_data(is_admin)
data["name"] = Example::to_json_data(name)
data["middleName"] = Example::to_json_data(middle_name) unless middle_name.nil?
data
end
end
# .rbs file
class Properties
attr_accessor is_admin: bool
attr_accessor name: String
attr_accessor middle_name: String?
def self.from_json_data: (untyped) -> Properties
def to_json_data: () -> untyped
end
Code generated from “Values” schemas
“Values” schemas will be converted into
a Ruby Hash
from String
s to T
s (in RBS syntax: Hash[String, T]
), where
T
is the type of the values of the object:
{
"values": {
"type": "string"
}
}
Generates into:
# .rb file
class Values
attr_accessor :value
def self.from_json_data(data)
out = Values.new
out.value = Example.from_json_data(Hash[String, String], data)
out
end
def to_json_data
Example.to_json_data(value)
end
end
# .rbs file
class Values
attr_accessor value: Hash[String, String]
def self.from_json_data: (untyped) -> Values
def to_json_data: () -> untyped
end
Note: jtd-codegen
had to generate a custom type alias here, which is why the
code has a bit of extra stuff. If you use “empty”, “type”, “ref”, “elements”, or
“values” schemas at the top level of a schema, jtd-codegen
has to emit type
aliases in Ruby. In real-world schemas, this doesn’t happen very often.
Code generated from “Discriminator” schemas
“Discriminator” schemas will be converted into a plain old Ruby class, and each mapping will be a separate Ruby class that extends the discriminator:
{
"discriminator": "eventType",
"mapping": {
"USER_CREATED": {
"properties": {
"id": { "type": "string" }
}
},
"USER_PAYMENT_PLAN_CHANGED": {
"properties": {
"id": { "type": "string" },
"plan": { "enum": ["FREE", "PAID"] }
}
},
"USER_DELETED": {
"properties": {
"id": { "type": "string" },
"softDelete": { "type": "boolean" }
}
}
}
}
Generates into:
# .rb file
class Discriminator
attr_accessor :event_type
def self.from_json_data(data)
{
"USER_CREATED" => DiscriminatorUserCreated,
"USER_DELETED" => DiscriminatorUserDeleted,
"USER_PAYMENT_PLAN_CHANGED" => DiscriminatorUserPaymentPlanChanged,
}[data["eventType"]].from_json_data(data)
end
end
class DiscriminatorUserCreated < Discriminator
attr_accessor :id
def self.from_json_data(data)
out = DiscriminatorUserCreated.new
out.event_type = "USER_CREATED"
out.id = Example::from_json_data(String, data["id"])
out
end
def to_json_data
data = { "eventType" => "USER_CREATED" }
data["id"] = Example::to_json_data(id)
data
end
end
class DiscriminatorUserDeleted < Discriminator
attr_accessor :id
attr_accessor :soft_delete
def self.from_json_data(data)
out = DiscriminatorUserDeleted.new
out.event_type = "USER_DELETED"
out.id = Example::from_json_data(String, data["id"])
out.soft_delete = Example::from_json_data(TrueClass, data["softDelete"])
out
end
def to_json_data
data = { "eventType" => "USER_DELETED" }
data["id"] = Example::to_json_data(id)
data["softDelete"] = Example::to_json_data(soft_delete)
data
end
end
class DiscriminatorUserPaymentPlanChangedPlan
attr_accessor :value
def initialize(value)
self.value = value
end
private_class_method :new
FREE = new("FREE")
PAID = new("PAID")
def self.from_json_data(data)
{
"FREE" => FREE,
"PAID" => PAID,
}[data]
end
def to_json_data
value
end
end
class DiscriminatorUserPaymentPlanChanged < Discriminator
attr_accessor :id
attr_accessor :plan
def self.from_json_data(data)
out = DiscriminatorUserPaymentPlanChanged.new
out.event_type = "USER_PAYMENT_PLAN_CHANGED"
out.id = Example::from_json_data(String, data["id"])
out.plan = Example::from_json_data(DiscriminatorUserPaymentPlanChangedPlan, data["plan"])
out
end
def to_json_data
data = { "eventType" => "USER_PAYMENT_PLAN_CHANGED" }
data["id"] = Example::to_json_data(id)
data["plan"] = Example::to_json_data(plan)
data
end
end
# .rbs file
class Discriminator
attr_accessor event_type: String
def self.from_json_data: (untyped) -> Discriminator
def to_json_data: () -> untyped
end
class DiscriminatorUserCreated < Discriminator
attr_accessor id: String
def self.from_json_data: (untyped) -> DiscriminatorUserCreated
def to_json_data: () -> untyped
end
class DiscriminatorUserDeleted < Discriminator
attr_accessor id: String
attr_accessor soft_delete: bool
def self.from_json_data: (untyped) -> DiscriminatorUserDeleted
def to_json_data: () -> untyped
end
class DiscriminatorUserPaymentPlanChangedPlan
attr_accessor value: String
FREE: DiscriminatorUserPaymentPlanChangedPlan
PAID: DiscriminatorUserPaymentPlanChangedPlan
def self.from_json_data: (untyped) -> DiscriminatorUserPaymentPlanChangedPlan
def to_json_data: () -> untyped
end
class DiscriminatorUserPaymentPlanChanged < Discriminator
attr_accessor id: String
attr_accessor plan: DiscriminatorUserPaymentPlanChangedPlan
def self.from_json_data: (untyped) -> DiscriminatorUserPaymentPlanChanged
def to_json_data: () -> untyped
end
Section title
-
-
-
-
Tooling
-
Advanced Concepts
-
Language-Specific Documentation