jsonapi-resources 0.8.3 → 0.9.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,5 +1,7 @@
1
1
  require 'jsonapi/naive_cache'
2
+ require 'jsonapi/compiled_json'
2
3
  require 'jsonapi/resource'
4
+ require 'jsonapi/cached_resource_fragment'
3
5
  require 'jsonapi/response_document'
4
6
  require 'jsonapi/acts_as_resource_controller'
5
7
  require 'jsonapi/resource_controller'
@@ -2,14 +2,12 @@ require 'csv'
2
2
 
3
3
  module JSONAPI
4
4
  module ActsAsResourceController
5
- MEDIA_TYPE_MATCHER = /(.+".+"[^,]*|[^,]+)/
5
+ MEDIA_TYPE_MATCHER = /.+".+"[^,]*|[^,]+/
6
6
  ALL_MEDIA_TYPES = '*/*'
7
7
 
8
8
  def self.included(base)
9
9
  base.extend ClassMethods
10
10
  base.include Callbacks
11
- base.before_action :ensure_correct_media_type, only: [:create, :update, :create_relationship, :update_relationship]
12
- base.before_action :ensure_valid_accept_media_type
13
11
  base.cattr_reader :server_error_callbacks
14
12
  base.define_jsonapi_resources_callbacks :process_operations
15
13
  end
@@ -27,18 +25,22 @@ module JSONAPI
27
25
  end
28
26
 
29
27
  def create
28
+ return unless verify_content_type_header
30
29
  process_request
31
30
  end
32
31
 
33
32
  def create_relationship
33
+ return unless verify_content_type_header
34
34
  process_request
35
35
  end
36
36
 
37
37
  def update_relationship
38
+ return unless verify_content_type_header
38
39
  process_request
39
40
  end
40
41
 
41
42
  def update
43
+ return unless verify_content_type_header
42
44
  process_request
43
45
  end
44
46
 
@@ -59,23 +61,29 @@ module JSONAPI
59
61
  end
60
62
 
61
63
  def process_request
64
+ return unless verify_accept_header
65
+
62
66
  @request = JSONAPI::RequestParser.new(params, context: context,
63
67
  key_formatter: key_formatter,
64
68
  server_error_callbacks: (self.class.server_error_callbacks || []))
69
+
65
70
  unless @request.errors.empty?
66
71
  render_errors(@request.errors)
67
72
  else
68
- process_operations
69
- render_results(@operation_results)
73
+ operations = @request.operations
74
+ unless JSONAPI.configuration.resource_cache.nil?
75
+ operations.each {|op| op.options[:cache_serializer] = resource_serializer }
76
+ end
77
+ results = process_operations(operations)
78
+ render_results(results)
70
79
  end
71
-
72
80
  rescue => e
73
81
  handle_exceptions(e)
74
82
  end
75
83
 
76
- def process_operations
84
+ def process_operations(operations)
77
85
  run_callbacks :process_operations do
78
- @operation_results = operation_dispatcher.process(@request.operations)
86
+ operation_dispatcher.process(operations)
79
87
  end
80
88
  end
81
89
 
@@ -109,6 +117,19 @@ module JSONAPI
109
117
  @resource_serializer_klass ||= JSONAPI::ResourceSerializer
110
118
  end
111
119
 
120
+ def resource_serializer
121
+ @resource_serializer ||= resource_serializer_klass.new(
122
+ resource_klass,
123
+ include_directives: @request ? @request.include_directives : nil,
124
+ fields: @request ? @request.fields : {},
125
+ base_url: base_url,
126
+ key_formatter: key_formatter,
127
+ route_formatter: route_formatter,
128
+ serialization_options: serialization_options
129
+ )
130
+ @resource_serializer
131
+ end
132
+
112
133
  def base_url
113
134
  @base_url ||= request.protocol + request.host_with_port
114
135
  end
@@ -117,34 +138,38 @@ module JSONAPI
117
138
  @resource_klass_name ||= "#{self.class.name.underscore.sub(/_controller$/, '').singularize}_resource".camelize
118
139
  end
119
140
 
120
- def ensure_correct_media_type
141
+ def verify_content_type_header
121
142
  unless request.content_type == JSONAPI::MEDIA_TYPE
122
143
  fail JSONAPI::Exceptions::UnsupportedMediaTypeError.new(request.content_type)
123
144
  end
145
+ true
124
146
  rescue => e
125
147
  handle_exceptions(e)
148
+ false
126
149
  end
127
150
 
128
- def ensure_valid_accept_media_type
151
+ def verify_accept_header
129
152
  unless valid_accept_media_type?
130
153
  fail JSONAPI::Exceptions::NotAcceptableError.new(request.accept)
131
154
  end
155
+ true
132
156
  rescue => e
133
157
  handle_exceptions(e)
158
+ false
134
159
  end
135
160
 
136
161
  def valid_accept_media_type?
137
162
  media_types = media_types_for('Accept')
138
163
 
139
164
  media_types.blank? ||
140
- media_types.any? do |media_type|
141
- (media_type == JSONAPI::MEDIA_TYPE || media_type == ALL_MEDIA_TYPES)
142
- end
165
+ media_types.any? do |media_type|
166
+ (media_type == JSONAPI::MEDIA_TYPE || media_type.start_with?(ALL_MEDIA_TYPES))
167
+ end
143
168
  end
144
169
 
145
170
  def media_types_for(header)
146
171
  (request.headers[header] || '')
147
- .match(MEDIA_TYPE_MATCHER)
172
+ .scan(MEDIA_TYPE_MATCHER)
148
173
  .to_a
149
174
  .map(&:strip)
150
175
  end
@@ -198,34 +223,36 @@ module JSONAPI
198
223
 
199
224
  def render_results(operation_results)
200
225
  response_doc = create_response_document(operation_results)
226
+ content = response_doc.contents
201
227
 
202
- render_options = {
203
- status: response_doc.status,
204
- json: response_doc.contents,
205
- content_type: JSONAPI::MEDIA_TYPE
206
- }
228
+ render_options = {}
229
+ if operation_results.has_errors?
230
+ render_options[:json] = content
231
+ else
232
+ # Bypasing ActiveSupport allows us to use CompiledJson objects for cached response fragments
233
+ render_options[:body] = JSON.generate(content)
234
+ end
207
235
 
208
- render_options[:location] = response_doc.contents[:data]["links"][:self] if (
209
- response_doc.status == :created && response_doc.contents[:data].class != Array
236
+ render_options[:location] = content[:data]["links"][:self] if (
237
+ response_doc.status == :created && content[:data].class != Array
210
238
  )
211
239
 
240
+ # For whatever reason, `render` ignores :status and :content_type when :body is set.
241
+ # But, we can just set those values directly in the Response object instead.
242
+ response.status = response_doc.status
243
+ response.headers['Content-Type'] = JSONAPI::MEDIA_TYPE
244
+
212
245
  render(render_options)
213
246
  end
214
247
 
215
248
  def create_response_document(operation_results)
216
249
  JSONAPI::ResponseDocument.new(
217
250
  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,
251
+ operation_results.has_errors? ? nil : resource_serializer,
222
252
  key_formatter: key_formatter,
223
- route_formatter: route_formatter,
224
253
  base_meta: base_meta,
225
254
  base_links: base_response_links,
226
- resource_serializer_klass: resource_serializer_klass,
227
- request: @request,
228
- serialization_options: serialization_options
255
+ request: @request
229
256
  )
230
257
  end
231
258
 
@@ -239,6 +266,10 @@ module JSONAPI
239
266
  if JSONAPI.configuration.exception_class_whitelisted?(e)
240
267
  fail e
241
268
  else
269
+ (self.class.server_error_callbacks || []).each { |callback|
270
+ safe_run_callback(callback, e)
271
+ }
272
+
242
273
  internal_server_error = JSONAPI::Exceptions::InternalServerError.new(e)
243
274
  Rails.logger.error { "Internal Server Error: #{e.message} #{e.backtrace.join("\n")}" }
244
275
  render_errors(internal_server_error.errors)
@@ -246,6 +277,16 @@ module JSONAPI
246
277
  end
247
278
  end
248
279
 
280
+ def safe_run_callback(callback, error)
281
+ begin
282
+ callback.call(error)
283
+ rescue => e
284
+ Rails.logger.error { "Error in error handling callback: #{e.message} #{e.backtrace.join("\n")}" }
285
+ internal_server_error = JSONAPI::Exceptions::InternalServerError.new(e)
286
+ render_errors(internal_server_error.errors)
287
+ end
288
+ end
289
+
249
290
  # Pass in a methods or a block to be run when an exception is
250
291
  # caught that is not a JSONAPI::Exceptions::Error
251
292
  # Useful for additional logging or notification configuration that
@@ -0,0 +1,119 @@
1
+ module JSONAPI
2
+ class CachedResourceFragment
3
+ def self.fetch_fragments(resource_klass, serializer, context, cache_ids)
4
+ serializer_config_key = serializer.config_key(resource_klass).gsub("/", "_")
5
+ context_json = resource_klass.attribute_caching_context(context).to_json
6
+ context_b64 = JSONAPI.configuration.resource_cache_digest_function.call(context_json)
7
+ context_key = "ATTR-CTX-#{context_b64.gsub("/", "_")}"
8
+
9
+ results = self.lookup(resource_klass, serializer_config_key, context_key, cache_ids)
10
+
11
+ miss_ids = results.select{|k,v| v.nil? }.keys
12
+ unless miss_ids.empty?
13
+ find_filters = {resource_klass._primary_key => miss_ids.uniq}
14
+ find_options = {context: context}
15
+ resource_klass.find(find_filters, find_options).each do |resource|
16
+ (id, cr) = write(resource_klass, resource, serializer, serializer_config_key, context_key)
17
+ results[id] = cr
18
+ end
19
+ end
20
+
21
+ if JSONAPI.configuration.resource_cache_usage_report_function
22
+ JSONAPI.configuration.resource_cache_usage_report_function.call(
23
+ resource_klass.name,
24
+ cache_ids.size - miss_ids.size,
25
+ miss_ids.size
26
+ )
27
+ end
28
+
29
+ return results
30
+ end
31
+
32
+ def self.from_cache_value(resource_klass, h)
33
+ new(
34
+ resource_klass,
35
+ h.fetch(:id),
36
+ h.fetch(:type),
37
+ h.fetch(:fetchable),
38
+ h.fetch(:rels, nil),
39
+ h.fetch(:links, nil),
40
+ h.fetch(:attrs, nil),
41
+ h.fetch(:meta, nil)
42
+ )
43
+ end
44
+
45
+ attr_reader :resource_klass, :id, :type, :fetchable_fields, :relationships,
46
+ :links_json, :attributes_json, :meta_json,
47
+ :preloaded_fragments
48
+
49
+ def initialize(resource_klass, id, type, fetchable_fields, relationships,
50
+ links_json, attributes_json, meta_json)
51
+ @resource_klass = resource_klass
52
+ @id = id
53
+ @type = type
54
+ @fetchable_fields = Set.new(fetchable_fields)
55
+
56
+ # Relationships left uncompiled because we'll often want to insert included ids on retrieval
57
+ @relationships = relationships
58
+
59
+ @links_json = CompiledJson.of(links_json)
60
+ @attributes_json = CompiledJson.of(attributes_json)
61
+ @meta_json = CompiledJson.of(meta_json)
62
+
63
+ # A hash of hashes
64
+ @preloaded_fragments ||= Hash.new
65
+ end
66
+
67
+ def to_cache_value
68
+ {
69
+ id: id,
70
+ type: type,
71
+ fetchable: fetchable_fields,
72
+ rels: relationships,
73
+ links: links_json.try(:to_s),
74
+ attrs: attributes_json.try(:to_s),
75
+ meta: meta_json.try(:to_s)
76
+ }
77
+ end
78
+
79
+ private
80
+
81
+ def self.lookup(resource_klass, serializer_config_key, context_key, cache_ids)
82
+ type = resource_klass._type
83
+
84
+ keys = cache_ids.map do |(id, cache_key)|
85
+ [type, id, cache_key, serializer_config_key, context_key]
86
+ end
87
+
88
+ hits = JSONAPI.configuration.resource_cache.read_multi(*keys).reject{|_,v| v.nil? }
89
+ return keys.each_with_object({}) do |key, hash|
90
+ (_, id, _, _) = key
91
+ if hits.has_key?(key)
92
+ hash[id] = self.from_cache_value(resource_klass, hits[key])
93
+ else
94
+ hash[id] = nil
95
+ end
96
+ end
97
+ end
98
+
99
+ def self.write(resource_klass, resource, serializer, serializer_config_key, context_key)
100
+ (id, cache_key) = resource.cache_id
101
+ json = serializer.object_hash(resource) # No inclusions passed to object_hash
102
+ cr = self.new(
103
+ resource_klass,
104
+ json['id'],
105
+ json['type'],
106
+ resource.fetchable_fields,
107
+ json['relationships'],
108
+ json['links'],
109
+ json['attributes'],
110
+ json['meta']
111
+ )
112
+
113
+ key = [resource_klass._type, id, cache_key, serializer_config_key, context_key]
114
+ JSONAPI.configuration.resource_cache.write(key, cr.to_cache_value)
115
+ return [id, cr]
116
+ end
117
+
118
+ end
119
+ end
@@ -0,0 +1,36 @@
1
+ module JSONAPI
2
+ class CompiledJson
3
+ def self.compile(h)
4
+ new(JSON.generate(h), h)
5
+ end
6
+
7
+ def self.of(obj)
8
+ case obj
9
+ when NilClass then nil
10
+ when CompiledJson then obj
11
+ when String then CompiledJson.new(obj)
12
+ when Hash then CompiledJson.compile(obj)
13
+ else raise "Can't figure out how to turn #{obj.inspect} into CompiledJson"
14
+ end
15
+ end
16
+
17
+ def initialize(json, h = nil)
18
+ @json = json
19
+ @h = h
20
+ end
21
+
22
+ def to_json(*args)
23
+ @json
24
+ end
25
+
26
+ def to_s
27
+ @json
28
+ end
29
+
30
+ def to_h
31
+ @h ||= JSON.parse(@json)
32
+ end
33
+
34
+ undef_method :as_json
35
+ end
36
+ end
@@ -22,11 +22,17 @@ module JSONAPI
22
22
  :top_level_meta_include_page_count,
23
23
  :top_level_meta_page_count_key,
24
24
  :allow_transactions,
25
+ :include_backtraces_in_errors,
25
26
  :exception_class_whitelist,
27
+ :whitelist_all_exceptions,
26
28
  :always_include_to_one_linkage_data,
27
29
  :always_include_to_many_linkage_data,
28
30
  :cache_formatters,
29
- :use_relationship_reflection
31
+ :use_relationship_reflection,
32
+ :resource_cache,
33
+ :default_resource_cache_field,
34
+ :resource_cache_digest_function,
35
+ :resource_cache_usage_report_function
30
36
 
31
37
  def initialize
32
38
  #:underscored_key, :camelized_key, :dasherized_key, or custom
@@ -64,6 +70,10 @@ module JSONAPI
64
70
 
65
71
  self.use_text_errors = false
66
72
 
73
+ # Whether or not to include exception backtraces in JSONAPI error
74
+ # responses. Defaults to `false` in production, and `true` otherwise.
75
+ self.include_backtraces_in_errors = !Rails.env.production?
76
+
67
77
  # List of classes that should not be rescued by the operations processor.
68
78
  # For example, if you use Pundit for authorization, you might
69
79
  # raise a Pundit::NotAuthorizedError at some point during operations
@@ -72,6 +82,10 @@ module JSONAPI
72
82
  # the `Pundit::NotAuthorizedError` to the `exception_class_whitelist`.
73
83
  self.exception_class_whitelist = []
74
84
 
85
+ # If enabled, will override configuration option `exception_class_whitelist`
86
+ # and whitelist all exceptions.
87
+ self.whitelist_all_exceptions = false
88
+
75
89
  # Resource Linkage
76
90
  # Controls the serialization of resource linkage for non compound documents
77
91
  # NOTE: always_include_to_many_linkage_data is not currently implemented
@@ -88,12 +102,35 @@ module JSONAPI
88
102
 
89
103
  # Formatter Caching
90
104
  # Set to false to disable caching of string operations on keys and links.
105
+ # Note that unlike the resource cache, formatter caching is always done
106
+ # internally in-memory and per-thread; no ActiveSupport::Cache is used.
91
107
  self.cache_formatters = true
92
108
 
93
109
  # Relationship reflection invokes the related resource when updates
94
110
  # are made to a has_many relationship. By default relationship_reflection
95
111
  # is turned off because it imposes a small performance penalty.
96
112
  self.use_relationship_reflection = false
113
+
114
+ # Resource cache
115
+ # An ActiveSupport::Cache::Store or similar, used by Resources with caching enabled.
116
+ # Set to `nil` (the default) to disable caching, or to `Rails.cache` to use the
117
+ # Rails cache store.
118
+ self.resource_cache = nil
119
+
120
+ # Default resource cache field
121
+ # On Resources with caching enabled, this field will be used to check for out-of-date
122
+ # cache entries, unless overridden on a specific Resource. Defaults to "updated_at".
123
+ self.default_resource_cache_field = :updated_at
124
+
125
+ # Resource cache digest function
126
+ # Provide a callable that returns a unique value for string inputs with
127
+ # low chance of collision. The default is SHA256 base64.
128
+ self.resource_cache_digest_function = Digest::SHA2.new.method(:base64digest)
129
+
130
+ # Resource cache usage reporting
131
+ # Optionally provide a callable which JSONAPI will call with information about cache
132
+ # performance. Should accept three arguments: resource name, hits count, misses count.
133
+ self.resource_cache_usage_report_function = nil
97
134
  end
98
135
 
99
136
  def cache_formatters=(bool)
@@ -109,14 +146,14 @@ module JSONAPI
109
146
 
110
147
  def json_key_format=(format)
111
148
  @json_key_format = format
112
- if @cache_formatters
149
+ if defined?(@cache_formatters)
113
150
  @key_formatter_tlv = Concurrent::ThreadLocalVar.new
114
151
  end
115
152
  end
116
153
 
117
154
  def route_format=(format)
118
155
  @route_format = format
119
- if @cache_formatters
156
+ if defined?(@cache_formatters)
120
157
  @route_formatter_tlv = Concurrent::ThreadLocalVar.new
121
158
  end
122
159
  end
@@ -156,7 +193,8 @@ module JSONAPI
156
193
  end
157
194
 
158
195
  def exception_class_whitelisted?(e)
159
- @exception_class_whitelist.flatten.any? { |k| e.class.ancestors.include?(k) }
196
+ @whitelist_all_exceptions ||
197
+ @exception_class_whitelist.flatten.any? { |k| e.class.ancestors.map(&:to_s).include?(k.to_s) }
160
198
  end
161
199
 
162
200
  def default_processor_klass=(default_processor_klass)
@@ -185,8 +223,12 @@ module JSONAPI
185
223
 
186
224
  attr_writer :allow_transactions
187
225
 
226
+ attr_writer :include_backtraces_in_errors
227
+
188
228
  attr_writer :exception_class_whitelist
189
229
 
230
+ attr_writer :whitelist_all_exceptions
231
+
190
232
  attr_writer :always_include_to_one_linkage_data
191
233
 
192
234
  attr_writer :always_include_to_many_linkage_data
@@ -194,6 +236,14 @@ module JSONAPI
194
236
  attr_writer :raise_if_parameters_not_allowed
195
237
 
196
238
  attr_writer :use_relationship_reflection
239
+
240
+ attr_writer :resource_cache
241
+
242
+ attr_writer :default_resource_cache_field
243
+
244
+ attr_writer :resource_cache_digest_function
245
+
246
+ attr_writer :resource_cache_usage_report_function
197
247
  end
198
248
 
199
249
  class << self