jsonapi-resources 0.8.3 → 0.9.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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