@@ -54,10 +54,76 @@ def register_renderers
54
54
# Renderer proc is evaluated in the controller context.
55
55
headers [ 'Content-Type' ] = Mime [ :jsonapi ] . to_s
56
56
57
- ActiveSupport ::Notifications . instrument ( 'render.jsonapi-rails' ,
58
- resources : resources ,
59
- options : options ) do
60
- JSON . generate ( renderer . render ( resources , options , self ) )
57
+ ActiveSupport ::Notifications . instrument (
58
+ 'render.jsonapi-rails' ,
59
+ resources : resources ,
60
+ options : options
61
+ ) do
62
+ # Depending on whether or not a valid cache object is present
63
+ # in the options, the #render call below will return two
64
+ # slightly different kinds of hash.
65
+ #
66
+ # Both hashes have broadly the following structure, where r is
67
+ # some representation of a JSON::API resource:
68
+ #
69
+ # {
70
+ # data: [ r1, r2, r3 ],
71
+ # meta: { count: 12345 },
72
+ # jsonapi: { version: "1.0" }
73
+ # }
74
+ #
75
+ # For non-cached calls to this method, the `data` field in the
76
+ # return value will contain an array of Ruby hashes.
77
+ #
78
+ # For cached calls, the `data` field will contain an array of
79
+ # JSON strings corresponding to the same data. This happens
80
+ # because jsonapi-renderer caches both the JSON serialization
81
+ # step as well as the assembly of the relevant attributes into
82
+ # a JSON::API-compliant structure. Those JSON strings are
83
+ # created via calls to `to_json`. They are then wrapped in
84
+ # CachedResourcesProcessor::JSONString. This defines a
85
+ # `to_json` method which simply returns self, ie - it attempts
86
+ # to ensure that any further `to_json` calls result in no
87
+ # changes.
88
+ #
89
+ # That isn't what happens in a Rails context, however. Below,
90
+ # the last step is to convert the entire output hash of the
91
+ # renderer into a JSON string to send to the client. If we
92
+ # call `to_json` on the cached output, the already-made JSON
93
+ # strings in the `data` field will be converted again,
94
+ # resulting in malformed data reaching the client. This happens
95
+ # because the ActiveSupport `to_json` takes precedent, meaning
96
+ # the "no-op" `to_json` definition on JSONString never gets
97
+ # executed.
98
+ #
99
+ # We can get around this by using JSON.generate instead, which
100
+ # will use the `to_json` defined on JSONString rather than the
101
+ # ActiveSupport one.
102
+ #
103
+ # However, we can't use JSON.generate on the non-cached output.
104
+ # Doing so means that its `data` field contents are converted
105
+ # with a non-ActiveSupport `to_json`. This means cached and
106
+ # non-cached responses have subtle differences in how their
107
+ # resources are serialized. For example:
108
+ #
109
+ # x = Time.new(2021,1,1)
110
+ #
111
+ # x.to_json
112
+ # => "\"2021-01-01T00:00:00.000+00:00\""
113
+ #
114
+ # JSON.generate x
115
+ # => "\"2021-01-01 00:00:00 +0000\""
116
+ #
117
+ # The different outputs mean we need to take different
118
+ # approaches when converting the entire payload into JSON,
119
+ # hence the check below.
120
+ jsonapi_hash = renderer . render ( resources , options , self )
121
+
122
+ if jsonapi_hash [ :data ] &.first &.class == JSONAPI ::Renderer ::CachedResourcesProcessor ::JSONString
123
+ JSON . generate jsonapi_hash
124
+ else
125
+ jsonapi_hash . to_json
126
+ end
61
127
end
62
128
end
63
129
end
0 commit comments