Skip to content

Commit e3ac80d

Browse files
committed
Change how renderer output is converted to JSON
Update the railtie to fix issues with the way renderer output is converted into a JSON string in the controller. Cached and non-cached resources need slightly different handling, so make a logical check to tell the difference and use the appropriate method.
1 parent d59b6b1 commit e3ac80d

File tree

1 file changed

+70
-4
lines changed

1 file changed

+70
-4
lines changed

Diff for: lib/jsonapi/rails/railtie.rb

+70-4
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,76 @@ def register_renderers
5454
# Renderer proc is evaluated in the controller context.
5555
headers['Content-Type'] = Mime[:jsonapi].to_s
5656

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
61127
end
62128
end
63129
end

0 commit comments

Comments
 (0)