Skip to content

Instantly share code, notes, and snippets.

@asterite
Last active May 5, 2018 11:49
Show Gist options
  • Save asterite/db0ee7947d7eceb17c894fabffffafc4 to your computer and use it in GitHub Desktop.
Save asterite/db0ee7947d7eceb17c894fabffffafc4 to your computer and use it in GitHub Desktop.
How JSON serialization would look like if Crystal had user-defined annotations like Java/C#
require "spec"
require "json"
require "uuid"
require "uuid/json"
require "big/json"
module JSON
annotation Field
end
module Serializable
macro included
def initialize(pull : JSON::PullParser)
# TODO: Hack to bypass uninitialized instance vars, remove once fixed
\{% @type %}
super
end
end
def initialize(pull : ::JSON::PullParser)
{% begin %}
{% properties = {} of Nil => Nil %}
{% for ivar in @type.instance_vars %}
{% ann = ivar.annotation(::JSON::Field) %}
{% unless ann && ann[:ignore] %}
{%
properties[ivar.id] = {
type: ivar.type,
key: ((ann && ann[:key]) || ivar).id.stringify,
has_default: ivar.has_default_value?,
default: ivar.default_value,
nilable: ivar.type.nilable?,
root: ann && ann[:root],
converter: ann && ann[:converter],
presence: ann && ann[:presence],
}
%}
{% end %}
{% end %}
{% for name, value in properties %}
%var{name} = nil
%found{name} = false
{% end %}
%location = pull.location
begin
pull.read_begin_object
rescue exc : ::JSON::ParseException
raise ::JSON::MappingError.new(self.class, *%location, cause: exc)
end
while pull.kind != :end_object
%key_location = pull.location
key = pull.read_object_key
case key
{% for name, value in properties %}
when {{value[:key]}}
%found{name} = true
begin
%var{name} =
{% if value[:nilable] || value[:has_default] %} pull.read_null_or { {% end %}
{% if value[:root] %}
pull.on_key!({{value[:root]}}) do
{% end %}
{% if value[:converter] %}
{{value[:converter]}}.from_json(pull)
{% else %}
::Union({{value[:type]}}).new(pull)
{% end %}
{% if value[:root] %}
end
{% end %}
{% if value[:nilable] || value[:has_default] %} } {% end %}
rescue exc : ::JSON::ParseException
raise ::JSON::MappingError.new(self.class, {{value[:key]}}, *%key_location, cause: exc)
end
{% end %}
else
on_unknown_json_attribute(pull, key, %key_location)
end
end
pull.read_next
{% for name, value in properties %}
{% unless value[:nilable] || value[:has_default] %}
if %var{name}.nil? && !%found{name} && !::Union({{value[:type]}}).nilable?
raise ::JSON::MappingError.new("Missing JSON attribute: {{value[:key].id}}", self.class, *%location)
end
{% end %}
{% if value[:nilable] %}
{% if value[:has_default] != nil %}
@{{name}} = %found{name} ? %var{name} : {{value[:default]}}
{% else %}
@{{name}} = %var{name}
{% end %}
{% elsif value[:has_default] %}
@{{name}} = %var{name}.nil? ? {{value[:default]}} : %var{name}
{% else %}
@{{name}} = (%var{name}).as({{value[:type]}})
{% end %}
{% if value[:presence] %}
@{{name}}_present = %found{name}
{% end %}
{% end %}
{% end %}
end
def on_unknown_json_attribute(pull, key, key_location)
pull.skip
end
def to_json(json : ::JSON::Builder)
{% begin %}
{% properties = {} of Nil => Nil %}
{% for ivar in @type.instance_vars %}
{% ann = ivar.annotation(::JSON::Field) %}
{% unless ann && ann[:ignore] %}
{%
properties[ivar.id] = {
type: ivar.type,
key: ((ann && ann[:key]) || ivar).id.stringify,
root: ann && ann[:root],
converter: ann && ann[:converter],
emit_null: ann && ann[:emit_null],
}
%}
{% end %}
{% end %}
json.object do
{% for name, value in properties %}
_{{name}} = @{{name}}
{% unless value[:emit_null] %}
unless _{{name}}.nil?
{% end %}
json.field({{value[:key]}}) do
{% if value[:root] %}
{% if value[:emit_null] %}
if _{{name}}.nil?
nil.to_json(json)
else
{% end %}
json.object do
json.field({{value[:root]}}) do
{% end %}
{% if value[:converter] %}
if _{{name}}
{{ value[:converter] }}.to_json(_{{name}}, json)
else
nil.to_json(json)
end
{% else %}
_{{name}}.to_json(json)
{% end %}
{% if value[:root] %}
{% if value[:emit_null] %}
end
{% end %}
end
end
{% end %}
end
{% unless value[:emit_null] %}
end
{% end %}
{% end %}
end
{% end %}
end
end
end
class JSONPerson
include JSON::Serializable
property name : String
property age : Int32?
def_equals name, age
def initialize(@name : String)
end
end
class StrictJSONPerson
include JSON::Serializable
property name : String
property age : Int32?
def on_unknown_json_attribute(pull, key, key_location)
raise ::JSON::MappingError.new("Unknown JSON attribute: #{key}", self.class, *key_location)
end
end
class JSONPersonEmittingNull
include JSON::Serializable
property name : String
@[JSON::Field(emit_null: true)]
property age : Int32?
end
class JSONWithBool
include JSON::Serializable
property value : Bool
end
class JSONWithUUID
include JSON::Serializable
property value : UUID
end
class JSONWithBigDecimal
include JSON::Serializable
property value : BigDecimal
end
class JSONWithTime
include JSON::Serializable
@[JSON::Field(converter: Time::Format.new("%F %T"))]
property value : Time
end
class JSONWithNilableTime
include JSON::Serializable
@[JSON::Field(converter: Time::Format.new("%F"))]
property value : Time?
def initialize
end
end
class JSONWithNilableTimeEmittingNull
include JSON::Serializable
@[JSON::Field(converter: Time::Format.new("%F"), emit_null: true)]
property value : Time?
def initialize
end
end
class JSONWithPropertiesKey
include JSON::Serializable
property properties : Hash(String, String)
end
class JSONWithSimpleMapping
include JSON::Serializable
property name : String
property age : Int32
end
class JSONWithKeywordsMapping
include JSON::Serializable
property end : Int32
property abstract : Int32
end
class JSONWithAny
include JSON::Serializable
property name : String
property any : JSON::Any
end
class JsonWithProblematicKeys
include JSON::Serializable
property key : Int32
property pull : Int32
end
class JsonWithSet
include JSON::Serializable
property set : Set(String)
end
class JsonWithDefaults
include JSON::Serializable
property a = 11
property b = "Haha"
property c = true
property d = false
property e : Bool? = false
property f : Int32? = 1
property g : Int32?
property h = [1, 2, 3]
end
class JSONWithSmallIntegers
include JSON::Serializable
property foo : Int16
property bar : Int8
end
class JSONWithTimeEpoch
include JSON::Serializable
@[JSON::Field(converter: Time::EpochConverter)]
property value : Time
end
class JSONWithTimeEpochMillis
include JSON::Serializable
@[JSON::Field(converter: Time::EpochMillisConverter)]
property value : Time
end
class JSONWithRaw
include JSON::Serializable
@[JSON::Field(converter: String::RawConverter)]
property value : String
end
class JSONWithRoot
include JSON::Serializable
@[JSON::Field(root: "heroes")]
property result : Array(JSONPerson)
end
class JSONWithNilableRoot
include JSON::Serializable
@[JSON::Field(root: "heroes")]
property result : Array(JSONPerson)?
end
class JSONWithNilableRootEmitNull
include JSON::Serializable
@[JSON::Field(root: "heroes", emit_null: true)]
property result : Array(JSONPerson)?
end
class JSONWithNilableUnion
include JSON::Serializable
property value : Int32?
end
class JSONWithNilableUnion2
include JSON::Serializable
property value : Int32 | Nil
end
class JSONWithPresence
include JSON::Serializable
@[JSON::Field(presence: true)]
property first_name : String?
@[JSON::Field(presence: true)]
property last_name : String?
@[JSON::Field(ignore: true)]
getter? first_name_present : Bool
@[JSON::Field(ignore: true)]
getter? last_name_present : Bool
end
class JSONWithQueryAttributes
include JSON::Serializable
property? foo : Bool
@[JSON::Field(key: "is_bar", presence: true)]
property? bar : Bool = false
@[JSON::Field(ignore: true)]
getter? bar_present : Bool
end
describe "JSON mapping" do
it "parses person" do
person = JSONPerson.from_json(%({"name": "John", "age": 30}))
person.should be_a(JSONPerson)
person.name.should eq("John")
person.age.should eq(30)
end
it "parses person without age" do
person = JSONPerson.from_json(%({"name": "John"}))
person.should be_a(JSONPerson)
person.name.should eq("John")
person.name.size.should eq(4) # This verifies that name is not nilable
person.age.should be_nil
end
it "parses array of people" do
people = Array(JSONPerson).from_json(%([{"name": "John"}, {"name": "Doe"}]))
people.size.should eq(2)
end
it "does to_json" do
person = JSONPerson.from_json(%({"name": "John", "age": 30}))
person2 = JSONPerson.from_json(person.to_json)
person2.should eq(person)
end
it "parses person with unknown attributes" do
person = JSONPerson.from_json(%({"name": "John", "age": 30, "foo": "bar"}))
person.should be_a(JSONPerson)
person.name.should eq("John")
person.age.should eq(30)
end
it "parses strict person with unknown attributes" do
error_message = <<-'MSG'
Unknown JSON attribute: foo
parsing StrictJSONPerson
MSG
ex = expect_raises JSON::MappingError, error_message do
StrictJSONPerson.from_json <<-JSON
{
"name": "John",
"age": 30,
"foo": "bar"
}
JSON
end
ex.location.should eq({4, 3})
end
it "raises if non-nilable attribute is nil" do
error_message = <<-'MSG'
Missing JSON attribute: name
parsing JSONPerson at 1:1
MSG
ex = expect_raises JSON::MappingError, error_message do
JSONPerson.from_json(%({"age": 30}))
end
ex.location.should eq({1, 1})
end
it "raises if not an object" do
error_message = <<-'MSG'
Expected begin_object but was string at 1:1
parsing StrictJSONPerson at 0:0
MSG
ex = expect_raises JSON::MappingError, error_message do
StrictJSONPerson.from_json <<-JSON
"foo"
JSON
end
ex.location.should eq({1, 1})
end
it "raises if data type does not match" do
error_message = <<-MSG
Couldn't parse (Int32 | Nil) from "foo" at 3:10
MSG
ex = expect_raises JSON::MappingError, error_message do
StrictJSONPerson.from_json <<-JSON
{
"name": "John",
"age": "foo",
"foo": "bar"
}
JSON
end
ex.location.should eq({3, 10})
end
it "doesn't emit null by default when doing to_json" do
person = JSONPerson.from_json(%({"name": "John"}))
(person.to_json =~ /age/).should be_falsey
end
it "emits null on request when doing to_json" do
person = JSONPersonEmittingNull.from_json(%({"name": "John"}))
(person.to_json =~ /age/).should be_truthy
end
it "doesn't raises on false value when not-nil" do
json = JSONWithBool.from_json(%({"value": false}))
json.value.should be_false
end
it "parses UUID" do
uuid = JSONWithUUID.from_json(%({"value": "ba714f86-cac6-42c7-8956-bcf5105e1b81"}))
uuid.should be_a(JSONWithUUID)
uuid.value.should eq(UUID.new("ba714f86-cac6-42c7-8956-bcf5105e1b81"))
end
it "parses json with Time::Format converter" do
json = JSONWithTime.from_json(%({"value": "2014-10-31 23:37:16"}))
json.value.should be_a(Time)
json.value.to_s.should eq("2014-10-31 23:37:16 UTC")
json.to_json.should eq(%({"value":"2014-10-31 23:37:16"}))
end
it "allows setting a nilable property to nil" do
person = JSONPerson.new("John")
person.age = 1
person.age = nil
end
it "parses simple mapping" do
person = JSONWithSimpleMapping.from_json(%({"name": "John", "age": 30}))
person.should be_a(JSONWithSimpleMapping)
person.name.should eq("John")
person.age.should eq(30)
end
it "outputs with converter when nilable" do
json = JSONWithNilableTime.new
json.to_json.should eq("{}")
end
it "outputs with converter when nilable when emit_null is true" do
json = JSONWithNilableTimeEmittingNull.new
json.to_json.should eq(%({"value":null}))
end
it "outputs JSON with properties key" do
input = {
properties: {"foo" => "bar"},
}.to_json
json = JSONWithPropertiesKey.from_json(input)
json.to_json.should eq(input)
end
it "parses json with keywords" do
json = JSONWithKeywordsMapping.from_json(%({"end": 1, "abstract": 2}))
json.end.should eq(1)
json.abstract.should eq(2)
end
it "parses json with any" do
json = JSONWithAny.from_json(%({"name": "Hi", "any": [{"x": 1}, 2, "hey", true, false, 1.5, null]}))
json.name.should eq("Hi")
json.any.raw.should eq([{"x" => 1}, 2, "hey", true, false, 1.5, nil])
json.to_json.should eq(%({"name":"Hi","any":[{"x":1},2,"hey",true,false,1.5,null]}))
end
it "parses json with problematic keys" do
json = JsonWithProblematicKeys.from_json(%({"key": 1, "pull": 2}))
json.key.should eq(1)
json.pull.should eq(2)
end
it "parses json array as set" do
json = JsonWithSet.from_json(%({"set": ["a", "a", "b"]}))
json.set.should eq(Set(String){"a", "b"})
end
it "allows small types of integer" do
json = JSONWithSmallIntegers.from_json(%({"foo": 23, "bar": 7}))
json.foo.should eq(23)
typeof(json.foo).should eq(Int16)
json.bar.should eq(7)
typeof(json.bar).should eq(Int8)
end
describe "parses json with defaults" do
it "mixed" do
json = JsonWithDefaults.from_json(%({"a":1,"b":"bla"}))
json.a.should eq 1
json.b.should eq "bla"
json = JsonWithDefaults.from_json(%({"a":1}))
json.a.should eq 1
json.b.should eq "Haha"
json = JsonWithDefaults.from_json(%({"b":"bla"}))
json.a.should eq 11
json.b.should eq "bla"
json = JsonWithDefaults.from_json(%({}))
json.a.should eq 11
json.b.should eq "Haha"
json = JsonWithDefaults.from_json(%({"a":null,"b":null}))
json.a.should eq 11
json.b.should eq "Haha"
end
it "bool" do
json = JsonWithDefaults.from_json(%({}))
json.c.should eq true
typeof(json.c).should eq Bool
json.d.should eq false
typeof(json.d).should eq Bool
json = JsonWithDefaults.from_json(%({"c":false}))
json.c.should eq false
json = JsonWithDefaults.from_json(%({"c":true}))
json.c.should eq true
json = JsonWithDefaults.from_json(%({"d":false}))
json.d.should eq false
json = JsonWithDefaults.from_json(%({"d":true}))
json.d.should eq true
end
it "with nilable" do
json = JsonWithDefaults.from_json(%({}))
json.e.should eq false
typeof(json.e).should eq(Bool | Nil)
json.f.should eq 1
typeof(json.f).should eq(Int32 | Nil)
json.g.should eq nil
typeof(json.g).should eq(Int32 | Nil)
json = JsonWithDefaults.from_json(%({"e":false}))
json.e.should eq false
json = JsonWithDefaults.from_json(%({"e":true}))
json.e.should eq true
end
it "create new array every time" do
json = JsonWithDefaults.from_json(%({}))
json.h.should eq [1, 2, 3]
json.h << 4
json.h.should eq [1, 2, 3, 4]
json = JsonWithDefaults.from_json(%({}))
json.h.should eq [1, 2, 3]
end
end
it "uses Time::EpochConverter" do
string = %({"value":1459859781})
json = JSONWithTimeEpoch.from_json(string)
json.value.should be_a(Time)
json.value.should eq(Time.epoch(1459859781))
json.to_json.should eq(string)
end
it "uses Time::EpochMillisConverter" do
string = %({"value":1459860483856})
json = JSONWithTimeEpochMillis.from_json(string)
json.value.should be_a(Time)
json.value.should eq(Time.epoch_ms(1459860483856))
json.to_json.should eq(string)
end
it "parses raw value from int" do
string = %({"value":123456789123456789123456789123456789})
json = JSONWithRaw.from_json(string)
json.value.should eq("123456789123456789123456789123456789")
json.to_json.should eq(string)
end
it "parses raw value from float" do
string = %({"value":123456789123456789.123456789123456789})
json = JSONWithRaw.from_json(string)
json.value.should eq("123456789123456789.123456789123456789")
json.to_json.should eq(string)
end
it "parses raw value from object" do
string = %({"value":[null,true,false,{"x":[1,1.5]}]})
json = JSONWithRaw.from_json(string)
json.value.should eq(%([null,true,false,{"x":[1,1.5]}]))
json.to_json.should eq(string)
end
it "parses with root" do
json = %({"result":{"heroes":[{"name":"Batman"}]}})
result = JSONWithRoot.from_json(json)
result.result.should be_a(Array(JSONPerson))
result.result.first.name.should eq "Batman"
result.to_json.should eq(json)
end
it "parses with nilable root" do
json = %({"result":null})
result = JSONWithNilableRoot.from_json(json)
result.result.should be_nil
result.to_json.should eq("{}")
end
it "parses with nilable root and emit null" do
json = %({"result":null})
result = JSONWithNilableRootEmitNull.from_json(json)
result.result.should be_nil
result.to_json.should eq(json)
end
it "parses nilable union" do
obj = JSONWithNilableUnion.from_json(%({"value": 1}))
obj.value.should eq(1)
obj.to_json.should eq(%({"value":1}))
obj = JSONWithNilableUnion.from_json(%({"value": null}))
obj.value.should be_nil
obj.to_json.should eq(%({}))
obj = JSONWithNilableUnion.from_json(%({}))
obj.value.should be_nil
obj.to_json.should eq(%({}))
end
it "parses nilable union2" do
obj = JSONWithNilableUnion2.from_json(%({"value": 1}))
obj.value.should eq(1)
obj.to_json.should eq(%({"value":1}))
obj = JSONWithNilableUnion2.from_json(%({"value": null}))
obj.value.should be_nil
obj.to_json.should eq(%({}))
obj = JSONWithNilableUnion2.from_json(%({}))
obj.value.should be_nil
obj.to_json.should eq(%({}))
end
describe "parses JSON with presence markers" do
it "parses person with absent attributes" do
json = JSONWithPresence.from_json(%({"first_name": null}))
json.first_name.should be_nil
json.first_name_present?.should be_true
json.last_name.should be_nil
json.last_name_present?.should be_false
end
end
describe "with query attributes" do
it "defines query getter" do
json = JSONWithQueryAttributes.from_json(%({"foo": true}))
json.foo?.should be_true
json.bar?.should be_false
end
it "defines query getter with class restriction" do
{% begin %}
{% methods = JSONWithQueryAttributes.methods %}
{{ methods.find(&.name.==("foo?")).return_type }}.should eq(Bool)
{{ methods.find(&.name.==("bar?")).return_type }}.should eq(Bool)
{% end %}
end
it "defines non-query setter and presence methods" do
json = JSONWithQueryAttributes.from_json(%({"foo": false}))
json.bar_present?.should be_false
json.bar = true
json.bar?.should be_true
end
it "maps non-query attributes" do
json = JSONWithQueryAttributes.from_json(%({"foo": false, "is_bar": false}))
json.bar_present?.should be_true
json.bar?.should be_false
json.bar = true
json.to_json.should eq(%({"foo":false,"is_bar":true}))
end
it "raises if non-nilable attribute is nil" do
error_message = <<-'MSG'
Missing JSON attribute: foo
parsing JSONWithQueryAttributes at 1:1
MSG
ex = expect_raises JSON::MappingError, error_message do
JSONWithQueryAttributes.from_json(%({"is_bar": true}))
end
ex.location.should eq({1, 1})
end
end
describe "BigDecimal" do
it "parses json string with BigDecimal" do
json = JSONWithBigDecimal.from_json(%({"value": "10.05"}))
json.value.should eq(BigDecimal.new("10.05"))
end
it "parses large json ints with BigDecimal" do
json = JSONWithBigDecimal.from_json(%({"value": 9223372036854775808}))
json.value.should eq(BigDecimal.new("9223372036854775808"))
end
it "parses json float with BigDecimal" do
json = JSONWithBigDecimal.from_json(%({"value": 10.05}))
json.value.should eq(BigDecimal.new("10.05"))
end
it "parses large precision json floats with BigDecimal" do
json = JSONWithBigDecimal.from_json(%({"value": 0.00045808999999999997}))
json.value.should eq(BigDecimal.new("0.00045808999999999997"))
end
end
end
puts
puts "Works with record"
record Point, x : Int32, y : Int32 do
include JSON::Serializable
end
puts Point.new(1, 2).to_json
puts Point.from_json(%({"x": 1, "y": 2}))
puts
puts "Works with inheritance and modules"
module Moo
property moo : Int32 = 10
end
class Foo
include Moo
include JSON::Serializable
@[JSON::Field(key: "phoo")]
property foo = 15
end
class Bar < Foo
property bar : Int32
end
p Foo.from_json(%({"phoo": 20}))
p Foo.from_json(%({}))
p Bar.from_json(%({"phoo": 20, "bar": 30}))
p Bar.from_json(%({"bar": 30, "moo": 40}))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment