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.
- 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
|