Skip to content

Commit df5a0f5

Browse files
authored
Merge pull request #712 from DavidMikeSimon/caching
Resource serialization fragment caching
2 parents 348b345 + 13efb05 commit df5a0f5

27 files changed

Lines changed: 1606 additions & 608 deletions

README.md

Lines changed: 132 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ backed by ActiveRecord models or by custom objects.
5151
* [Routing] (#routing)
5252
* [Nested Routes] (#nested-routes)
5353
* [Authorization](#authorization)
54+
* [Resource Caching] (#resource-caching)
55+
* [Caching Caveats] (#caching-caveats)
5456
* [Configuration] (#configuration)
5557
* [Contributing] (#contributing)
5658
* [License] (#license)
@@ -1644,10 +1646,11 @@ class DefaultValueFormatter < JSONAPI::ValueFormatter
16441646
class << self
16451647
def format(raw_value)
16461648
case raw_value
1647-
when String, Integer
1648-
return raw_value
1649+
when Date, Time, DateTime, ActiveSupport::TimeWithZone, BigDecimal
1650+
# Use the as_json methods added to various base classes by ActiveSupport
1651+
return raw_value.as_json
16491652
else
1650-
return raw_value.to_s
1653+
return raw_value
16511654
end
16521655
end
16531656
end
@@ -1680,16 +1683,14 @@ resource for a system wide change).
16801683
and
16811684
16821685
```ruby
1683-
class MyDefaultValueFormatter < JSONAPI::ValueFormatter
1686+
class MyDefaultValueFormatter < DefaultValueFormatter
16841687
class << self
16851688
def format(raw_value)
16861689
case raw_value
1687-
when String, Integer
1688-
return raw_value
16891690
when DateTime
1690-
return raw_value.in_time_zone('UTC').to_s
1691+
return super(raw_value.in_time_zone('UTC'))
16911692
else
1692-
return raw_value.to_s
1693+
return super
16931694
end
16941695
end
16951696
end
@@ -1905,6 +1906,106 @@ Currently `json-api-resources` doesn't come with built-in primitives for authori
19051906

19061907
Refer to the comments/discussion [here](https://github.com/cerebris/jsonapi-resources/issues/16#issuecomment-222438975) for the differences between approaches
19071908

1909+
### Resource Caching
1910+
1911+
To improve the response time of GET requests, JR can cache the generated JSON fragments for
1912+
Resources which are suitable. First, set `config.resource_cache` to an ActiveSupport cache store:
1913+
1914+
```ruby
1915+
JSONAPI.configure do |config|
1916+
config.resource_cache = Rails.cache
1917+
end
1918+
```
1919+
1920+
Then, on each Resource you want to cache, call the `caching` method:
1921+
1922+
```ruby
1923+
class PostResource < JSONAPI::Resource
1924+
caching
1925+
end
1926+
```
1927+
1928+
See the caveats section below for situations where you might not want to enable caching on particular
1929+
Resources.
1930+
1931+
The Resource model must also have a field that is updated whenever any of the model's data changes.
1932+
The default Rails timestamps handle this pretty well, and the default cache key field is `updated_at` for this reason.
1933+
You can use an alternate field (which you are then responsible for updating) by calling the `cache_field` method:
1934+
1935+
```ruby
1936+
class PostResource < JSONAPI::Resource
1937+
caching
1938+
cache_field :change_counter
1939+
1940+
before_save do
1941+
if self.change_counter.nil?
1942+
self.change_counter = 1
1943+
elsif self.changed?
1944+
self.change_counter += 1
1945+
end
1946+
end
1947+
1948+
after_touch do
1949+
update_attribute(:change_counter, self.change_counter + 1)
1950+
end
1951+
end
1952+
```
1953+
1954+
If context affects the content of the serialized result, you must define a class method `attribute_caching_context` on that Resource, which should return a different value for contexts that produce different results. In particular, if the `meta` or `fetchable_fields` methods, or any method providing the actual content of an attribute, changes depending on context, then you must provide `attribute_caching_context`. The actual value it
1955+
returns isn't important, what matters is that the value must be different if any relevant part of the context is different.
1956+
1957+
```ruby
1958+
class PostResource < JSONAPI::Resource
1959+
caching
1960+
1961+
attributes :title, :body, :secret_field
1962+
1963+
def fetchable_fields
1964+
return super if context.user.superuser?
1965+
return super - [:secret_field]
1966+
end
1967+
1968+
def meta
1969+
if context.user.can_see_creation_dates?
1970+
return { created: _model.created_at }
1971+
else
1972+
return {}
1973+
end
1974+
end
1975+
1976+
def self.attribute_caching_context(context)
1977+
return {
1978+
admin: context.user.superuser?,
1979+
creation_date_viewer: context.user.can_see_creation_dates?
1980+
}
1981+
end
1982+
end
1983+
```
1984+
1985+
#### Caching Caveats
1986+
1987+
* Models for cached Resources must update a cache key field whenever their data changes. However, if you bypass Rails and e.g. alter the database row directly without changing the `updated_at` field, the cached entry for that resource will be inaccurate. Also, `updated_at` provides a narrow race condition window; if a resource is updated twice in the same second, it's possible that only the first update will be cached. If you're concerned about this, you will need to find a way to make sure your models' cache fields change on every update, e.g. by using a unique random value or a monotonic clock.
1988+
* If an attribute's value is affected by related resources, e.g. the `spoken_languages` example above, then changes to the related resource must also touch the cache field on the resource that uses it. The `belongs_to` relation in ActiveRecord provides a `:touch` option for this purpose.
1989+
* JR does not actively clean the cache, so you must use an ActiveSupport cache that automatically expires old entries, or you will leak resources. The MemoryCache built in to Rails does this by default, but other caches will have to be configured with an `:expires_in` option and/or a cache-specific clearing mechanism.
1990+
* Similarly, if you make a substantial code change that affects a lot of serialized representations (i.e. changing the way an attribute is shown), you'll have to clear out all relevant cache entries yourself. The simplest way to do this is to run `JSONAPI.configuration.resource_cache.clear` from the console. You do not have to do this after merely adding or removing attributes; only changes that affect the actual content of attributes require manual cache clearing.
1991+
* If resource caching is enabled at all, then custom relationship methods on any resource might not always be used, even resources that are not cached. For example, if you manually define a `comments` method or `records_for_comments` method on a Resource that `has_many :comments`, you cannot expect it to be used when caching is enabled, even if you never call `caching` on that particular Resource. Instead, you should use relationship name lambdas.
1992+
* The above also applies to custom `find` or `find_by_key` methods. Instead, if you are using resource caching anywhere in your app, try overriding the `find_records` method to return an appropriate `ActiveRecord::Relation`.
1993+
* Caching relies on ActiveRecord features; you cannot enable caching on resources based on non-AR models, e.g. PORO objects or singleton resources.
1994+
* If you write a custom `ResourceSerializer` which takes new options, then you must define `config_description` to include those options if they might impact the serialized value:
1995+
1996+
```ruby
1997+
class MySerializer < JSONAPI::ResourceSerializer
1998+
def initialize(primary_resource_klass, options = {})
1999+
@my_special_option = options.delete(:my_special_option)
2000+
super
2001+
end
2002+
2003+
def config_description(resource_klass)
2004+
super.merge({my_special_option: @my_special_option})
2005+
end
2006+
end
2007+
```
2008+
19082009
## Configuration
19092010
19102011
JR has a few configuration options. Some have already been mentioned above. To set configuration options create an
@@ -1983,7 +2084,30 @@ JSONAPI.configure do |config|
19832084

19842085
# Formatter Caching
19852086
# Set to false to disable caching of string operations on keys and links.
2087+
# Note that unlike the resource cache, formatter caching is always done
2088+
# internally in-memory and per-thread; no ActiveSupport::Cache is used.
19862089
config.cache_formatters = true
2090+
2091+
# Resource cache
2092+
# An ActiveSupport::Cache::Store or similar, used by Resources with caching enabled.
2093+
# Set to `nil` (the default) to disable caching, or to `Rails.cache` to use the
2094+
# Rails cache store.
2095+
config.resource_cache = nil
2096+
2097+
# Default resource cache field
2098+
# On Resources with caching enabled, this field will be used to check for out-of-date
2099+
# cache entries, unless overridden on a specific Resource. Defaults to "updated_at".
2100+
config.default_resource_cache_field = :updated_at
2101+
2102+
# Resource cache digest function
2103+
# Provide a callable that returns a unique value for string inputs with
2104+
# low chance of collision. The default is SHA256 base64.
2105+
config.resource_cache_digest_function = Digest::SHA2.new.method(:base64digest)
2106+
2107+
# Resource cache usage reporting
2108+
# Optionally provide a callable which JSONAPI will call with information about cache
2109+
# performance. Should accept three arguments: resource name, hits count, misses count.
2110+
config.resource_cache_usage_report_function = nil
19872111
end
19882112
```
19892113

lib/jsonapi-resources.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
require 'jsonapi/naive_cache'
2+
require 'jsonapi/compiled_json'
23
require 'jsonapi/resource'
4+
require 'jsonapi/cached_resource_fragment'
35
require 'jsonapi/response_document'
46
require 'jsonapi/acts_as_resource_controller'
57
require 'jsonapi/resource_controller'

lib/jsonapi/acts_as_resource_controller.rb

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -62,20 +62,24 @@ def process_request
6262
@request = JSONAPI::RequestParser.new(params, context: context,
6363
key_formatter: key_formatter,
6464
server_error_callbacks: (self.class.server_error_callbacks || []))
65+
6566
unless @request.errors.empty?
6667
render_errors(@request.errors)
6768
else
68-
process_operations
69-
render_results(@operation_results)
69+
operations = @request.operations
70+
unless JSONAPI.configuration.resource_cache.nil?
71+
operations.each {|op| op.options[:cache_serializer] = resource_serializer }
72+
end
73+
results = process_operations(operations)
74+
render_results(results)
7075
end
71-
7276
rescue => e
7377
handle_exceptions(e)
7478
end
7579

76-
def process_operations
80+
def process_operations(operations)
7781
run_callbacks :process_operations do
78-
@operation_results = operation_dispatcher.process(@request.operations)
82+
operation_dispatcher.process(operations)
7983
end
8084
end
8185

@@ -109,6 +113,19 @@ def resource_serializer_klass
109113
@resource_serializer_klass ||= JSONAPI::ResourceSerializer
110114
end
111115

116+
def resource_serializer
117+
@resource_serializer ||= resource_serializer_klass.new(
118+
resource_klass,
119+
include_directives: @request ? @request.include_directives : nil,
120+
fields: @request ? @request.fields : {},
121+
base_url: base_url,
122+
key_formatter: key_formatter,
123+
route_formatter: route_formatter,
124+
serialization_options: serialization_options
125+
)
126+
@resource_serializer
127+
end
128+
112129
def base_url
113130
@base_url ||= request.protocol + request.host_with_port
114131
end
@@ -198,34 +215,36 @@ def render_errors(errors)
198215

199216
def render_results(operation_results)
200217
response_doc = create_response_document(operation_results)
218+
content = response_doc.contents
201219

202-
render_options = {
203-
status: response_doc.status,
204-
json: response_doc.contents,
205-
content_type: JSONAPI::MEDIA_TYPE
206-
}
220+
render_options = {}
221+
if operation_results.has_errors?
222+
render_options[:json] = content
223+
else
224+
# Bypasing ActiveSupport allows us to use CompiledJson objects for cached response fragments
225+
render_options[:body] = JSON.generate(content)
226+
end
207227

208-
render_options[:location] = response_doc.contents[:data]["links"][:self] if (
209-
response_doc.status == :created && response_doc.contents[:data].class != Array
228+
render_options[:location] = content[:data]["links"][:self] if (
229+
response_doc.status == :created && content[:data].class != Array
210230
)
211231

232+
# For whatever reason, `render` ignores :status and :content_type when :body is set.
233+
# But, we can just set those values directly in the Response object instead.
234+
response.status = response_doc.status
235+
response.headers['Content-Type'] = JSONAPI::MEDIA_TYPE
236+
212237
render(render_options)
213238
end
214239

215240
def create_response_document(operation_results)
216241
JSONAPI::ResponseDocument.new(
217242
operation_results,
218-
primary_resource_klass: resource_klass,
219-
include_directives: @request ? @request.include_directives : nil,
220-
fields: @request ? @request.fields : nil,
221-
base_url: base_url,
243+
operation_results.has_errors? ? nil : resource_serializer,
222244
key_formatter: key_formatter,
223-
route_formatter: route_formatter,
224245
base_meta: base_meta,
225246
base_links: base_response_links,
226-
resource_serializer_klass: resource_serializer_klass,
227-
request: @request,
228-
serialization_options: serialization_options
247+
request: @request
229248
)
230249
end
231250

0 commit comments

Comments
 (0)