-
-
Save RX14/cec7fcb1af434a3138db4da6ddc1b0da to your computer and use it in GitHub Desktop.
How JSON serialization would look like if Crystal had user-defined annotations like Java/C#
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require "spec" | |
require "json" | |
require "uuid" | |
require "uuid/json" | |
require "big/json" | |
module JSON | |
annotation SerializableAnnotation | |
end | |
annotation FieldAnnotation | |
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::FieldAnnotation) %} | |
{% 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 | |
{% ann = @type.annotation(::JSON::SerializableAnnotation) %} | |
{% if ann && ann[:strict] %} | |
raise ::JSON::MappingError.new("Unknown JSON attribute: #{key}", self.class, *%key_location) | |
{% else %} | |
pull.skip | |
{% end %} | |
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 to_json(json : ::JSON::Builder) | |
{% begin %} | |
{% properties = {} of Nil => Nil %} | |
{% for ivar in @type.instance_vars %} | |
{% ann = ivar.annotation(::JSON::FieldAnnotation) %} | |
{% 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 | |
@[JSON::Serializable(strict: true)] | |
class StrictJSONPerson | |
include JSON::Serializable | |
property name : String | |
property age : Int32? | |
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