Skip to content

Commit 827f5db

Browse files
Serialization caching
1 parent 6a10162 commit 827f5db

26 files changed

Lines changed: 1529 additions & 608 deletions

README.md

Lines changed: 129 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,103 @@ 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+
cache_field :change_counter
1938+
1939+
before_save do
1940+
if self.change_counter.nil?
1941+
self.change_counter = 1
1942+
elsif self.changed?
1943+
self.change_counter += 1
1944+
end
1945+
end
1946+
1947+
after_touch do
1948+
update_attribute(:change_counter, self.change_counter + 1)
1949+
end
1950+
end
1951+
```
1952+
1953+
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
1954+
returns isn't important, except that the value must be different if any relevant aspect of the context is different.
1955+
1956+
```ruby
1957+
class PostResource << JSONAPI::Resource
1958+
attributes :title, :body, :secret_field
1959+
1960+
def fetchable_fields
1961+
return super if context.user.superuser?
1962+
return super - [:secret_field]
1963+
end
1964+
1965+
def meta
1966+
if context.user.can_see_creation_dates?
1967+
return { created: _model.created_at }
1968+
else
1969+
return {}
1970+
end
1971+
end
1972+
1973+
def self.attribute_caching_context(context)
1974+
return {
1975+
admin: context.user.superuser?,
1976+
creation_date_viewer: context.user.can_see_creation_dates?
1977+
}
1978+
end
1979+
end
1980+
```
1981+
1982+
#### Caching Caveats
1983+
1984+
* 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.
1985+
* 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.
1986+
* 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.
1987+
* 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. 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. The simplest way to do this is to run `JSONAPI.configuration.resource_cache.clear` from the console.
1988+
* 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.
1989+
* 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`.
1990+
* Caching relies on ActiveRecord features; you cannot enable caching on resources based on non-AR models, e.g. PORO objects or singleton resources.
1991+
* 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:
1992+
1993+
```ruby
1994+
class MySerializer << JSONAPI::ResourceSerializer
1995+
def initialize(primary_resource_klass, options = {})
1996+
@my_special_option = options.delete(:my_special_option)
1997+
super
1998+
end
1999+
2000+
def config_description(resource_klass)
2001+
super.merge({my_special_option: @my_special_option})
2002+
end
2003+
end
2004+
```
2005+
19082006
## Configuration
19092007
19102008
JR has a few configuration options. Some have already been mentioned above. To set configuration options create an
@@ -1983,7 +2081,30 @@ JSONAPI.configure do |config|
19832081

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

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
@@ -204,34 +221,36 @@ def render_errors(errors)
204221

205222
def render_results(operation_results)
206223
response_doc = create_response_document(operation_results)
224+
content = response_doc.contents
207225

208-
render_options = {
209-
status: response_doc.status,
210-
json: response_doc.contents,
211-
content_type: JSONAPI::MEDIA_TYPE
212-
}
226+
render_options = {}
227+
if operation_results.has_errors?
228+
render_options[:json] = content
229+
else
230+
# Bypasing ActiveSupport allows us to use CompiledJson objects for cached response fragments
231+
render_options[:body] = JSON.generate(content)
232+
end
213233

214-
render_options[:location] = response_doc.contents[:data]["links"][:self] if (
215-
response_doc.status == :created && response_doc.contents[:data].class != Array
234+
render_options[:location] = content[:data]["links"][:self] if (
235+
response_doc.status == :created && content[:data].class != Array
216236
)
217237

238+
# For whatever reason, `render` ignores :status and :content_type when :body is set.
239+
# But, we can just set those values directly in the Response object instead.
240+
response.status = response_doc.status
241+
response.headers['Content-Type'] = JSONAPI::MEDIA_TYPE
242+
218243
render(render_options)
219244
end
220245

221246
def create_response_document(operation_results)
222247
JSONAPI::ResponseDocument.new(
223248
operation_results,
224-
primary_resource_klass: resource_klass,
225-
include_directives: @request ? @request.include_directives : nil,
226-
fields: @request ? @request.fields : nil,
227-
base_url: base_url,
249+
operation_results.has_errors? ? nil : resource_serializer,
228250
key_formatter: key_formatter,
229-
route_formatter: route_formatter,
230251
base_meta: base_meta,
231252
base_links: base_response_links,
232-
resource_serializer_klass: resource_serializer_klass,
233-
request: @request,
234-
serialization_options: serialization_options
253+
request: @request
235254
)
236255
end
237256

0 commit comments

Comments
 (0)