forked from crystal-lang/crystal
-
Notifications
You must be signed in to change notification settings - Fork 0
/
mapping.cr
202 lines (181 loc) · 7.52 KB
/
mapping.cr
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
module JSON
# The `JSON.mapping` macro defines how an object is mapped to JSON.
#
# ### Example
#
# ```
# require "json"
#
# class Location
# JSON.mapping(
# lat: Float64,
# lng: Float64,
# )
# end
#
# class House
# JSON.mapping(
# address: String,
# location: {type: Location, nilable: true},
# )
# end
#
# house = House.from_json(%({"address": "Crystal Road 1234", "location": {"lat": 12.3, "lng": 34.5}}))
# house.address # => "Crystal Road 1234"
# house.location # => #<Location:0x10cd93d80 @lat=12.3, @lng=34.5>
# house.to_json # => %({"address":"Crystal Road 1234","location":{"lat":12.3,"lng":34.5}})
# ```
#
# ### Usage
#
# `JSON.mapping` must receive a series of named arguments, or a named tuple literal, or a hash literal,
# whose keys will define Crystal properties.
#
# The value of each key can be a single type (not a union type). Primitive types (numbers, string, boolean and nil)
# are supported, as well as custom objects which use `JSON.mapping` or define a `new` method
# that accepts a `JSON::PullParser` and returns an object from it.
#
# The value can also be another hash literal with the following options:
# * **type**: (required) the single type described above (you can use `JSON::Any` too)
# * **key**: the property name in the JSON document (as opposed to the property name in the Crystal code)
# * **nilable**: if `true`, the property can be `Nil`. Passing `T?` as a type has the same effect.
# * **default**: value to use if the property is missing in the JSON document, or if it's `null` and `nilable` was not set to `true`. If the default value creates a new instance of an object (for example `[1, 2, 3]` or `SomeObject.new`), a different instance will be used each time a JSON document is parsed.
# * **emit_null**: if `true`, emits a `null` value for nilable properties (by default nulls are not emitted)
# * **converter**: specify an alternate type for parsing and generation. The converter must define `from_json(JSON::PullParser)` and `to_json(value, JSON::Builder)` as class methods. Examples of converters are `Time::Format` and `Time::EpochConverter` for `Time`.
# * **root**: assume the value is inside a JSON object with a given key (see `Object.from_json(string_or_io, root)`)
# * **setter**: if `true`, will generate a setter for the variable, `true` by default
# * **getter**: if `true`, will generate a getter for the variable, `true` by default
#
# This macro by default defines getters and setters for each variable (this can be overrided with *setter* and *getter*).
# The mapping doesn't define a constructor accepting these variables as arguments, but you can provide an overload.
#
# The macro basically defines a constructor accepting a `JSON::PullParser` that reads from
# it and initializes this type's instance variables. It also defines a `to_json(JSON::Builder)` method
# by invoking `to_json(JSON::Builder)` on each of the properties (unless a converter is specified, in
# which case `to_json(value, JSON::Builder)` is invoked).
#
# This macro also declares instance variables of the types given in the mapping.
#
# If *strict* is `true`, unknown properties in the JSON
# document will raise a parse exception. The default is `false`, so unknown properties
# are silently ignored.
macro mapping(properties, strict = false)
{% for key, value in properties %}
{% properties[key] = {type: value} unless value.is_a?(HashLiteral) || value.is_a?(NamedTupleLiteral) %}
{% end %}
{% for key, value in properties %}
@{{key.id}} : {{value[:type]}} {{ (value[:nilable] ? "?" : "").id }}
{% if value[:setter] == nil ? true : value[:setter] %}
def {{key.id}}=(_{{key.id}} : {{value[:type]}} {{ (value[:nilable] ? "?" : "").id }})
@{{key.id}} = _{{key.id}}
end
{% end %}
{% if value[:getter] == nil ? true : value[:getter] %}
def {{key.id}}
@{{key.id}}
end
{% end %}
{% end %}
def initialize(%pull : ::JSON::PullParser)
{% for key, value in properties %}
%var{key.id} = nil
%found{key.id} = false
{% end %}
%pull.read_object do |key|
case key
{% for key, value in properties %}
when {{value[:key] || key.id.stringify}}
%found{key.id} = true
%var{key.id} =
{% if value[:nilable] || value[:default] != nil %} %pull.read_null_or { {% end %}
{% if value[:root] %}
%pull.on_key!({{value[:root]}}) do
{% end %}
{% if value[:converter] %}
{{value[:converter]}}.from_json(%pull)
{% elsif value[:type].is_a?(Path) || value[:type].is_a?(Generic) %}
{{value[:type]}}.new(%pull)
{% else %}
::Union({{value[:type]}}).new(%pull)
{% end %}
{% if value[:root] %}
end
{% end %}
{% if value[:nilable] || value[:default] != nil %} } {% end %}
{% end %}
else
{% if strict %}
raise ::JSON::ParseException.new("Unknown json attribute: #{key}", 0, 0)
{% else %}
%pull.skip
{% end %}
end
end
{% for key, value in properties %}
{% unless value[:nilable] || value[:default] != nil %}
if %var{key.id}.nil? && !%found{key.id} && !::Union({{value[:type]}}).nilable?
raise ::JSON::ParseException.new("Missing json attribute: {{(value[:key] || key).id}}", 0, 0)
end
{% end %}
{% end %}
{% for key, value in properties %}
{% if value[:nilable] %}
{% if value[:default] != nil %}
@{{key.id}} = %found{key.id} ? %var{key.id} : {{value[:default]}}
{% else %}
@{{key.id}} = %var{key.id}
{% end %}
{% elsif value[:default] != nil %}
@{{key.id}} = %var{key.id}.nil? ? {{value[:default]}} : %var{key.id}
{% else %}
@{{key.id}} = (%var{key.id}).as({{value[:type]}})
{% end %}
{% end %}
end
def to_json(json : ::JSON::Builder)
json.object do
{% for key, value in properties %}
_{{key.id}} = @{{key.id}}
{% unless value[:emit_null] %}
unless _{{key.id}}.nil?
{% end %}
json.field({{value[:key] || key.id.stringify}}) do
{% if value[:root] %}
{% if value[:emit_null] %}
if _{{key.id}}.nil?
nil.to_json(json)
else
{% end %}
json.object do
json.field({{value[:root]}}) do
{% end %}
{% if value[:converter] %}
if _{{key.id}}
{{ value[:converter] }}.to_json(_{{key.id}}, json)
else
nil.to_json(json)
end
{% else %}
_{{key.id}}.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
# This is a convenience method to allow invoking `JSON.mapping`
# with named arguments instead of with a hash/named-tuple literal.
macro mapping(**properties)
::JSON.mapping({{properties}})
end
end