Skip to content

Commit 359d794

Browse files
authored
Merge pull request #1 from DFE-Digital/caching-bugfix
Change how renderer output is converted to JSON
2 parents d59b6b1 + c21e7c2 commit 359d794

File tree

4 files changed

+116
-13
lines changed

4 files changed

+116
-13
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
## Dummy app temp files:
1414
/spec/dummy/db
1515
/spec/dummy/log
16+
/spec/dummy/tmp
1617

1718
## Specific to RubyMotion:
1819
.dat*

jsonapi-rails.gemspec

+1
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ Gem::Specification.new do |spec|
2323
spec.add_development_dependency 'rspec-rails', '~> 3.5'
2424
spec.add_development_dependency 'with_model', '~> 2.0'
2525
spec.add_development_dependency 'simplecov'
26+
spec.add_development_dependency 'pry-byebug'
2627
end

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

spec/render_jsonapi_spec.rb

+44-9
Original file line numberDiff line numberDiff line change
@@ -23,32 +23,67 @@ def index
2323
def serializer
2424
Class.new(JSONAPI::Serializable::Resource) do
2525
type 'users'
26-
attribute :name
26+
attributes :id, :name, :dob
2727

2828
def jsonapi_cache_key(*)
29-
'foo'
29+
'cache_key'
3030
end
3131
end
3232
end
3333

34+
def user
35+
OpenStruct.new(id: 1, name: 'Johnny Cache', dob: Time.new(2021,1,1))
36+
end
37+
3438
def index
35-
user = OpenStruct.new(id: 1, name: 'Lucas')
39+
render jsonapi: [user],
40+
class: { OpenStruct: serializer }
41+
end
3642

37-
render jsonapi: user,
43+
def index_with_caching
44+
render jsonapi: [user],
3845
class: { OpenStruct: serializer },
3946
cache: Rails.cache
4047
end
4148
end
4249

43-
subject { JSON.parse(response.body) }
50+
before do
51+
routes.draw do
52+
get "index_with_caching" => "anonymous#index_with_caching"
53+
get "index" => "anonymous#index"
54+
end
55+
end
56+
57+
let(:rendered_json) { JSON.parse(response.body) }
4458

4559
it 'renders a JSON API success document' do
46-
get :index
47-
expect(Rails.cache.exist?('foo')).to be true
48-
get :index
60+
get :index_with_caching
4961

5062
expect(response.content_type).to eq('application/vnd.api+json')
51-
expect(subject.key?('data')).to be true
63+
expect(rendered_json.key?('data')).to be true
64+
end
65+
66+
it 'caches resources' do
67+
get :index_with_caching
68+
69+
expect(Rails.cache.exist?('cache_key')).to be true
70+
expect(JSON.parse(Rails.cache.read('cache_key'))).to eq rendered_json['data'].first
71+
end
72+
73+
it 'renders equivalent JSON whether caching or not' do
74+
expected_response = {
75+
"data"=>[{"id"=>"1", "type"=>"users", "attributes"=>{"id"=>1, "name"=>"Johnny Cache", "dob"=>"2021-01-01T00:00:00.000+00:00"}}],
76+
"jsonapi"=>{"version"=>"1.0"}
77+
}
78+
79+
get :index
80+
response_with_no_caching = rendered_json.deep_dup
81+
82+
get :index_with_caching
83+
response_with_caching = rendered_json
84+
85+
expect(response_with_no_caching).to eq expected_response
86+
expect(response_with_caching).to eq expected_response
5287
end
5388
end
5489
end

0 commit comments

Comments
 (0)