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.
- checksums.yaml +4 -4
- data/README.md +2124 -8
- data/lib/jsonapi-resources.rb +2 -0
- data/lib/jsonapi/acts_as_resource_controller.rb +70 -29
- data/lib/jsonapi/cached_resource_fragment.rb +119 -0
- data/lib/jsonapi/compiled_json.rb +36 -0
- data/lib/jsonapi/configuration.rb +54 -4
- data/lib/jsonapi/error_codes.rb +2 -2
- data/lib/jsonapi/exceptions.rb +19 -13
- data/lib/jsonapi/formatter.rb +15 -1
- data/lib/jsonapi/include_directives.rb +23 -3
- data/lib/jsonapi/processor.rb +69 -27
- data/lib/jsonapi/relationship_builder.rb +23 -21
- data/lib/jsonapi/request_parser.rb +27 -72
- data/lib/jsonapi/resource.rb +234 -38
- data/lib/jsonapi/resource_serializer.rb +229 -95
- data/lib/jsonapi/resources/version.rb +1 -1
- data/lib/jsonapi/response_document.rb +9 -20
- metadata +25 -9
data/lib/jsonapi-resources.rb
CHANGED
@@ -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
|
-
|
69
|
-
|
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
|
-
|
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
|
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
|
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
|
-
|
141
|
-
|
142
|
-
|
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
|
-
.
|
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
|
-
|
204
|
-
json
|
205
|
-
|
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] =
|
209
|
-
response_doc.status == :created &&
|
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
|
-
|
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
|
-
|
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
|
-
@
|
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
|