Skip to content

Commit fd8923c

Browse files
authored
Fragment caching (#8)
1 parent 1f31926 commit fd8923c

File tree

8 files changed

+333
-135
lines changed

8 files changed

+333
-135
lines changed

README.md

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class ResourceInterface
4848
# @return [String]
4949
def jsonapi_id; end
5050

51-
# Returns a hash containing, for each included relationship, an array of the
51+
# Returns a hash containing, for each included relationship, an array of the
5252
# resources to be included from that one.
5353
# @param included_relationships [Array<Symbol>] The keys of the relationships
5454
# to be included.
@@ -57,8 +57,8 @@ class ResourceInterface
5757

5858
# Returns a JSON API-compliant representation of the resource as a hash.
5959
# @param options [Hash]
60-
# @option fields [Array<Symbol>, Nil] The requested fields, or nil.
61-
# @option include [Array<Symbol>] The requested relationships to
60+
# @option fields [Set<Symbol>, Nil] The requested fields, or nil.
61+
# @option include [Set<Symbol>] The requested relationships to
6262
# include (defaults to []).
6363
# @return [Hash]
6464
def as_jsonapi(options = {}); end
@@ -100,6 +100,32 @@ returns a JSON API-compliant representation of the error.
100100

101101
This returns a JSON API compliant hash representing the described document.
102102

103+
### Caching
104+
105+
The generated JSON fragments can be cached in any cache implementation
106+
supporting the `fetch_multi` method.
107+
108+
When using caching, the serializable resources must implement an
109+
additional `jsonapi_cache_key` method:
110+
```ruby
111+
# Returns a cache key for the resource, parametered by the `include` and
112+
# `fields` options.
113+
# @param options [Hash]
114+
# @option fields [Set<Symbol>, Nil] The requested fields, or nil.
115+
# @option include [Set<Symbol>] The requested relationships to
116+
# include (defaults to []).
117+
# @return [String]
118+
def jsonapi_cache_key(options = {}); end
119+
```
120+
121+
The cache instance must be passed to the renderer as follows:
122+
```ruby
123+
JSONAPI.render(data: resources,
124+
include: include_string,
125+
fields: fields_hash,
126+
cache: cache_instance)
127+
```
128+
103129
## License
104130

105131
jsonapi-renderer is released under the [MIT License](http://www.opensource.org/licenses/MIT).
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
require 'jsonapi/renderer/resources_processor'
2+
3+
module JSONAPI
4+
class Renderer
5+
# @private
6+
class CachedResourcesProcessor < ResourcesProcessor
7+
class JSONString < String
8+
def to_json(*)
9+
self
10+
end
11+
end
12+
13+
def initialize(cache)
14+
@cache = cache
15+
end
16+
17+
def process_resources
18+
[@primary, @included].each do |resources|
19+
cache_hash = cache_key_map(resources)
20+
processed_resources = @cache.fetch_multi(cache_hash.keys) do |key|
21+
res, include, fields = cache_hash[key]
22+
json = res.as_jsonapi(include: include, fields: fields).to_json
23+
24+
JSONString.new(json)
25+
end
26+
27+
resources.replace(processed_resources.values)
28+
end
29+
end
30+
31+
def cache_key_map(resources)
32+
resources.each_with_object({}) do |res, h|
33+
ri = [res.jsonapi_type, res.jsonapi_id]
34+
include_dir = @include_rels[ri]
35+
fields = @fields[ri.first.to_sym]
36+
h[res.jsonapi_cache_key(include: include_dir, fields: fields)] =
37+
[res, include_dir, fields]
38+
end
39+
end
40+
end
41+
end
42+
end

lib/jsonapi/renderer/document.rb

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
require 'jsonapi/include_directive'
2-
require 'jsonapi/renderer/resources_processor'
2+
require 'jsonapi/renderer/simple_resources_processor'
3+
require 'jsonapi/renderer/cached_resources_processor'
34

45
module JSONAPI
56
class Renderer
@@ -12,6 +13,7 @@ def initialize(params = {})
1213
@fields = _symbolize_fields(params[:fields] || {})
1314
@jsonapi = params[:jsonapi]
1415
@include = JSONAPI::IncludeDirective.new(params[:include] || {})
16+
@cache = params[:cache]
1517
end
1618

1719
def to_hash
@@ -36,13 +38,21 @@ def document_hash
3638

3739
def data_hash
3840
primary, included =
39-
ResourcesProcessor.new(Array(@data), @include, @fields).process
41+
resources_processor.process(Array(@data), @include, @fields)
4042
{}.tap do |hash|
4143
hash[:data] = @data.respond_to?(:to_ary) ? primary : primary[0]
4244
hash[:included] = included if included.any?
4345
end
4446
end
4547

48+
def resources_processor
49+
if @cache
50+
CachedResourcesProcessor.new(@cache)
51+
else
52+
SimpleResourcesProcessor.new
53+
end
54+
end
55+
4656
def errors_hash
4757
{}.tap do |hash|
4858
hash[:errors] = @errors.flat_map(&:as_jsonapi)

lib/jsonapi/renderer/resources_processor.rb

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,13 @@
22

33
module JSONAPI
44
class Renderer
5+
# @api private
56
class ResourcesProcessor
6-
def initialize(resources, include, fields)
7+
def process(resources, include, fields)
78
@resources = resources
89
@include = include
910
@fields = fields
10-
end
1111

12-
def process
1312
traverse_resources
1413
process_resources
1514

@@ -73,14 +72,7 @@ def enqueue_resource(res, prefix, include_dir)
7372
end
7473

7574
def process_resources
76-
[@primary, @included].each do |resources|
77-
resources.map! do |res|
78-
ri = [res.jsonapi_type, res.jsonapi_id]
79-
include_dir = @include_rels[ri]
80-
fields = @fields[res.jsonapi_type.to_sym]
81-
res.as_jsonapi(include: include_dir, fields: fields)
82-
end
83-
end
75+
raise 'Not implemented'
8476
end
8577
end
8678
end
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
require 'jsonapi/renderer/resources_processor'
2+
3+
module JSONAPI
4+
class Renderer
5+
# @api private
6+
class SimpleResourcesProcessor < ResourcesProcessor
7+
def process_resources
8+
[@primary, @included].each do |resources|
9+
resources.map! do |res|
10+
ri = [res.jsonapi_type, res.jsonapi_id]
11+
include_dir = @include_rels[ri]
12+
fields = @fields[res.jsonapi_type.to_sym]
13+
res.as_jsonapi(include: include_dir, fields: fields)
14+
end
15+
end
16+
end
17+
end
18+
end
19+
end

spec/caching_spec.rb

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
require 'spec_helper'
2+
3+
class Cache
4+
def initialize
5+
@cache = {}
6+
end
7+
8+
def fetch_multi(keys)
9+
keys.each_with_object({}) do |k, h|
10+
@cache[k] = yield(k) unless @cache.key?(k)
11+
h[k] = @cache[k]
12+
end
13+
end
14+
end
15+
16+
describe JSONAPI::Renderer, '#render' do
17+
before(:all) do
18+
@users = [
19+
UserResource.new(1, 'User 1', '123 Example st.', []),
20+
UserResource.new(2, 'User 2', '234 Example st.', []),
21+
UserResource.new(3, 'User 3', '345 Example st.', []),
22+
UserResource.new(4, 'User 4', '456 Example st.', [])
23+
]
24+
@posts = [
25+
PostResource.new(1, 'Post 1', 'yesterday', @users[1]),
26+
PostResource.new(2, 'Post 2', 'today', @users[0]),
27+
PostResource.new(3, 'Post 3', 'tomorrow', @users[1])
28+
]
29+
@users[0].posts = [@posts[1]]
30+
@users[1].posts = [@posts[0], @posts[2]]
31+
end
32+
33+
it 'renders included relationships' do
34+
cache = Cache.new
35+
# Warm up the cache.
36+
subject.render(data: @users[0],
37+
include: 'posts',
38+
cache: cache)
39+
# Actual call on warm cache.
40+
actual = subject.render(data: @users[0],
41+
include: 'posts',
42+
cache: cache)
43+
expected = {
44+
data: {
45+
type: 'users',
46+
id: '1',
47+
attributes: {
48+
name: 'User 1',
49+
address: '123 Example st.'
50+
},
51+
relationships: {
52+
posts: {
53+
data: [{ type: 'posts', id: '2' }],
54+
links: {
55+
self: 'http://api.example.com/users/1/relationships/posts',
56+
related: {
57+
href: 'http://api.example.com/users/1/posts',
58+
meta: {
59+
do_not_use: true
60+
}
61+
}
62+
},
63+
meta: {
64+
deleted_posts: 5
65+
}
66+
}
67+
},
68+
links: {
69+
self: 'http://api.example.com/users/1'
70+
},
71+
meta: {
72+
user_meta: 'is_meta'
73+
}
74+
},
75+
included: [
76+
{
77+
type: 'posts',
78+
id: '2',
79+
attributes: {
80+
title: 'Post 2',
81+
date: 'today'
82+
},
83+
relationships: {
84+
author: {
85+
links: {
86+
self: 'http://api.example.com/posts/2/relationships/author',
87+
related: 'http://api.example.com/posts/2/author'
88+
},
89+
meta: {
90+
author_active: true
91+
}
92+
}
93+
}
94+
}
95+
]
96+
}
97+
98+
expect(JSON.parse(actual.to_json)).to eq(JSON.parse(expected.to_json))
99+
expect(actual[:data]).to be_a(JSONAPI::Renderer::CachedResourcesProcessor::JSONString)
100+
end
101+
end

0 commit comments

Comments
 (0)