jsonapi-resources 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +20 -0
- data/Gemfile +22 -0
- data/LICENSE.txt +22 -0
- data/README.md +451 -0
- data/Rakefile +24 -0
- data/jsonapi-resources.gemspec +29 -0
- data/lib/jsonapi-resources.rb +2 -0
- data/lib/jsonapi/active_record_operations_processor.rb +17 -0
- data/lib/jsonapi/association.rb +45 -0
- data/lib/jsonapi/error.rb +17 -0
- data/lib/jsonapi/error_codes.rb +16 -0
- data/lib/jsonapi/exceptions.rb +177 -0
- data/lib/jsonapi/operation.rb +151 -0
- data/lib/jsonapi/operation_result.rb +15 -0
- data/lib/jsonapi/operations_processor.rb +47 -0
- data/lib/jsonapi/request.rb +254 -0
- data/lib/jsonapi/resource.rb +417 -0
- data/lib/jsonapi/resource_controller.rb +169 -0
- data/lib/jsonapi/resource_for.rb +25 -0
- data/lib/jsonapi/resource_serializer.rb +209 -0
- data/lib/jsonapi/resources/version.rb +5 -0
- data/lib/jsonapi/routing_ext.rb +104 -0
- data/test/config/database.yml +5 -0
- data/test/controllers/controller_test.rb +940 -0
- data/test/fixtures/active_record.rb +585 -0
- data/test/helpers/functional_helpers.rb +59 -0
- data/test/helpers/hash_helpers.rb +13 -0
- data/test/helpers/value_matchers.rb +60 -0
- data/test/helpers/value_matchers_test.rb +40 -0
- data/test/integration/requests/request_test.rb +39 -0
- data/test/integration/routes/routes_test.rb +85 -0
- data/test/test_helper.rb +98 -0
- data/test/unit/operation/operations_processor_test.rb +188 -0
- data/test/unit/resource/resource_test.rb +45 -0
- data/test/unit/serializer/serializer_test.rb +429 -0
- metadata +193 -0
@@ -0,0 +1,169 @@
|
|
1
|
+
require 'jsonapi/resource_for'
|
2
|
+
require 'jsonapi/resource_serializer'
|
3
|
+
require 'action_controller'
|
4
|
+
require 'jsonapi/exceptions'
|
5
|
+
require 'jsonapi/error'
|
6
|
+
require 'jsonapi/error_codes'
|
7
|
+
require 'jsonapi/request'
|
8
|
+
require 'jsonapi/operations_processor'
|
9
|
+
require 'jsonapi/active_record_operations_processor'
|
10
|
+
require 'csv'
|
11
|
+
|
12
|
+
module JSONAPI
|
13
|
+
class ResourceController < ActionController::Base
|
14
|
+
include ResourceFor
|
15
|
+
|
16
|
+
before_filter {
|
17
|
+
begin
|
18
|
+
@request = JSONAPI::Request.new(context, params)
|
19
|
+
render_errors(@request.errors) unless @request.errors.empty?
|
20
|
+
rescue => e
|
21
|
+
handle_exceptions(e)
|
22
|
+
end
|
23
|
+
}
|
24
|
+
|
25
|
+
def index
|
26
|
+
render json: JSONAPI::ResourceSerializer.new.serialize(
|
27
|
+
resource_klass.find(resource_klass.verify_filters(@request.filters, context), context),
|
28
|
+
@request.includes,
|
29
|
+
@request.fields,
|
30
|
+
context)
|
31
|
+
rescue => e
|
32
|
+
handle_exceptions(e)
|
33
|
+
end
|
34
|
+
|
35
|
+
def show
|
36
|
+
keys = parse_key_array(params[resource_klass._key])
|
37
|
+
|
38
|
+
resources = []
|
39
|
+
keys.each do |key|
|
40
|
+
resources.push(resource_klass.find_by_key(key, context))
|
41
|
+
end
|
42
|
+
|
43
|
+
render json: JSONAPI::ResourceSerializer.new.serialize(
|
44
|
+
resources,
|
45
|
+
@request.includes,
|
46
|
+
@request.fields,
|
47
|
+
context)
|
48
|
+
rescue => e
|
49
|
+
handle_exceptions(e)
|
50
|
+
end
|
51
|
+
|
52
|
+
def show_association
|
53
|
+
association_type = params[:association]
|
54
|
+
|
55
|
+
parent_key = params[resource_klass._as_parent_key]
|
56
|
+
|
57
|
+
parent_resource = resource_klass.find_by_key(parent_key, context)
|
58
|
+
|
59
|
+
association = resource_klass._association(association_type)
|
60
|
+
render json: { association_type => parent_resource.send(association.key)}
|
61
|
+
rescue => e
|
62
|
+
handle_exceptions(e)
|
63
|
+
end
|
64
|
+
|
65
|
+
def create
|
66
|
+
process_request_operations
|
67
|
+
end
|
68
|
+
|
69
|
+
def create_association
|
70
|
+
process_request_operations
|
71
|
+
end
|
72
|
+
|
73
|
+
def update
|
74
|
+
process_request_operations
|
75
|
+
end
|
76
|
+
|
77
|
+
def destroy
|
78
|
+
process_request_operations
|
79
|
+
end
|
80
|
+
|
81
|
+
def destroy_association
|
82
|
+
process_request_operations
|
83
|
+
end
|
84
|
+
|
85
|
+
# Override this to use another operations processor
|
86
|
+
def create_operations_processor
|
87
|
+
JSONAPI::ActiveRecordOperationsProcessor.new
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
if RUBY_VERSION >= '2.0'
|
92
|
+
def resource_klass
|
93
|
+
@resource_klass ||= Object.const_get resource_klass_name
|
94
|
+
end
|
95
|
+
else
|
96
|
+
def resource_klass
|
97
|
+
@resource_klass ||= resource_klass_name.safe_constantize
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def resource_klass_name
|
102
|
+
@resource_klass_name ||= "#{self.class.name.demodulize.sub(/Controller$/, '').singularize}Resource"
|
103
|
+
end
|
104
|
+
|
105
|
+
def parse_key_array(raw)
|
106
|
+
keys = []
|
107
|
+
raw.split(/,/).collect do |key|
|
108
|
+
keys.push resource_klass.verify_key(key, context)
|
109
|
+
end
|
110
|
+
return keys
|
111
|
+
end
|
112
|
+
|
113
|
+
# override to set context
|
114
|
+
def context
|
115
|
+
{}
|
116
|
+
end
|
117
|
+
|
118
|
+
def render_errors(errors, status = :bad_request)
|
119
|
+
render(json: {errors: errors}, status: errors.count == 1 ? errors[0].status : status)
|
120
|
+
end
|
121
|
+
|
122
|
+
def process_request_operations
|
123
|
+
op = create_operations_processor
|
124
|
+
|
125
|
+
results = op.process(@request)
|
126
|
+
|
127
|
+
errors = []
|
128
|
+
resources = []
|
129
|
+
|
130
|
+
results.each do |result|
|
131
|
+
if result.has_errors?
|
132
|
+
errors.concat(result.errors)
|
133
|
+
else
|
134
|
+
resources.push(result.resource) unless result.resource.nil?
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
if errors.count > 0
|
139
|
+
render :status => errors.count == 1 ? errors[0].status : :bad_request, json: {errors: errors}
|
140
|
+
else
|
141
|
+
# if patch
|
142
|
+
render :status => results[0].code, json: JSONAPI::ResourceSerializer.new.serialize(resources,
|
143
|
+
@request.includes,
|
144
|
+
@request.fields,
|
145
|
+
context)
|
146
|
+
# else
|
147
|
+
# result_hash = {}
|
148
|
+
# resources.each do |resource|
|
149
|
+
# result_hash.merge!(JSONAPI::ResourceSerializer.new.serialize(resource, @request.includes, @request.fields, context))
|
150
|
+
# end
|
151
|
+
# render :status => results.count == 1 ? results[0].code : :ok, json: result_hash
|
152
|
+
# end
|
153
|
+
end
|
154
|
+
rescue => e
|
155
|
+
handle_exceptions(e)
|
156
|
+
end
|
157
|
+
|
158
|
+
# override this to process other exceptions
|
159
|
+
# Note: Be sure to either call super(e) or handle JSONAPI::Exceptions::Error and raise unhandled exceptions
|
160
|
+
def handle_exceptions(e)
|
161
|
+
case e
|
162
|
+
when JSONAPI::Exceptions::Error
|
163
|
+
render_errors(e.errors)
|
164
|
+
else # raise all other exceptions
|
165
|
+
raise e
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module JSONAPI
|
2
|
+
module ResourceFor
|
3
|
+
def self.included base
|
4
|
+
base.extend ClassMethods
|
5
|
+
end
|
6
|
+
|
7
|
+
module ClassMethods
|
8
|
+
if RUBY_VERSION >= '2.0'
|
9
|
+
def resource_for(type)
|
10
|
+
begin
|
11
|
+
resource_name = JSONAPI::Resource._resource_name_from_type(type)
|
12
|
+
Object.const_get resource_name if resource_name
|
13
|
+
rescue NameError
|
14
|
+
nil
|
15
|
+
end
|
16
|
+
end
|
17
|
+
else
|
18
|
+
def resource_for(type)
|
19
|
+
resource_name = JSONAPI::Resource._resource_name_from_type(type)
|
20
|
+
resource_name.safe_constantize if resource_name
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,209 @@
|
|
1
|
+
module JSONAPI
|
2
|
+
class ResourceSerializer
|
3
|
+
|
4
|
+
# Serializes a single resource, or an array of resources
|
5
|
+
# include:
|
6
|
+
# Purpose: determines which objects will be side loaded with the source objects in a linked section
|
7
|
+
# Example: ['comments','author','comments.tags','author.posts']
|
8
|
+
# fields:
|
9
|
+
# Purpose: determines which fields are serialized for a resource type. This encompasses both attributes and
|
10
|
+
# association ids in the links section for a resource. Fields are global for a resource type.
|
11
|
+
# Example: { people: [:id, :email, :comments], posts: [:id, :title, :author], comments: [:id, :body, :post]}
|
12
|
+
def serialize(source, include = [], fields = {}, context = nil)
|
13
|
+
@fields = fields
|
14
|
+
@context = context
|
15
|
+
@linked_objects = {}
|
16
|
+
|
17
|
+
requested_associations = parse_includes(include)
|
18
|
+
|
19
|
+
if source.respond_to?(:to_ary)
|
20
|
+
return {} if source.size == 0
|
21
|
+
@primary_class_name = source[0].class._serialize_as
|
22
|
+
else
|
23
|
+
@primary_class_name = source.class._serialize_as
|
24
|
+
end
|
25
|
+
|
26
|
+
process_primary(source, requested_associations)
|
27
|
+
|
28
|
+
primary_class_name = @primary_class_name.to_sym
|
29
|
+
primary_hash = {primary_class_name => []}
|
30
|
+
|
31
|
+
linked_hash = {}
|
32
|
+
@linked_objects.each do |class_name, objects|
|
33
|
+
class_name = class_name.to_sym
|
34
|
+
|
35
|
+
linked = []
|
36
|
+
objects.each_value do |object|
|
37
|
+
if object[:primary]
|
38
|
+
primary_hash[primary_class_name].push(object[:object_hash])
|
39
|
+
else
|
40
|
+
linked.push(object[:object_hash])
|
41
|
+
end
|
42
|
+
end
|
43
|
+
linked_hash[class_name] = linked unless linked.empty?
|
44
|
+
end
|
45
|
+
|
46
|
+
if linked_hash.size > 0
|
47
|
+
primary_hash.merge!({linked: linked_hash})
|
48
|
+
end
|
49
|
+
|
50
|
+
return primary_hash
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
# Convert an array of associated objects to include along with the primary document in the form of
|
55
|
+
# ['comments','author','comments.tags','author.posts'] into a structure that tells what we need to include
|
56
|
+
# from each association.
|
57
|
+
def parse_includes(includes)
|
58
|
+
requested_associations = {}
|
59
|
+
includes.each do |include|
|
60
|
+
include = include.to_s if include.is_a? Symbol
|
61
|
+
|
62
|
+
pos = include.index('.')
|
63
|
+
if pos
|
64
|
+
association_name = include[0, pos].to_sym
|
65
|
+
requested_associations[association_name] ||= {}
|
66
|
+
requested_associations[association_name].store(:include_children, true)
|
67
|
+
requested_associations[association_name].store(:include_related, parse_includes([include[pos+1, include.length]]))
|
68
|
+
else
|
69
|
+
association_name = include.to_sym
|
70
|
+
requested_associations[association_name] ||= {}
|
71
|
+
requested_associations[association_name].store(:include, true)
|
72
|
+
end
|
73
|
+
end if includes.is_a?(Array)
|
74
|
+
return requested_associations
|
75
|
+
end
|
76
|
+
|
77
|
+
# Process the primary source object(s). This will then serialize associated object recursively based on the
|
78
|
+
# requested includes. Fields are controlled fields option for each resource type, such
|
79
|
+
# as fields: { people: [:id, :email, :comments], posts: [:id, :title, :author], comments: [:id, :body, :post]}
|
80
|
+
# The fields options controls both fields and included links references.
|
81
|
+
def process_primary(source, requested_associations)
|
82
|
+
if source.respond_to?(:to_ary)
|
83
|
+
source.each do |object|
|
84
|
+
id = object.send(object.class._key)
|
85
|
+
if already_serialized?(@primary_class_name, id)
|
86
|
+
set_primary(@primary_class_name, id)
|
87
|
+
end
|
88
|
+
|
89
|
+
add_linked_object(@primary_class_name, id, object_hash(object, requested_associations), true)
|
90
|
+
end
|
91
|
+
else
|
92
|
+
id = source.send(source.class._key)
|
93
|
+
# ToDo: See if this is actually needed
|
94
|
+
# if already_serialized?(@primary_class_name, id)
|
95
|
+
# set_primary(@primary_class_name, id)
|
96
|
+
# end
|
97
|
+
|
98
|
+
add_linked_object(@primary_class_name, id, object_hash(source, requested_associations), true)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# Returns a serialized hash for the source object, with
|
103
|
+
def object_hash(source, requested_associations)
|
104
|
+
obj_hash = attribute_hash(source)
|
105
|
+
links = links_hash(source, requested_associations)
|
106
|
+
obj_hash.merge!({links: links}) unless links.empty?
|
107
|
+
return obj_hash
|
108
|
+
end
|
109
|
+
|
110
|
+
def requested_fields(model)
|
111
|
+
@fields[model] if @fields
|
112
|
+
end
|
113
|
+
|
114
|
+
def attribute_hash(source)
|
115
|
+
requested = requested_fields(source.class._serialize_as)
|
116
|
+
fields = source.class._attributes.to_a
|
117
|
+
unless requested.nil?
|
118
|
+
fields = requested & fields
|
119
|
+
end
|
120
|
+
|
121
|
+
source.fetchable(fields, @context).each_with_object({}) do |name, hash|
|
122
|
+
hash[name] = source.send(name)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
# Returns a hash of links for the requested associations for a resource, filtered by the resource
|
127
|
+
# class's fetchable method
|
128
|
+
def links_hash(source, requested_associations)
|
129
|
+
associations = source.class._associations
|
130
|
+
requested = requested_fields(source.class._serialize_as)
|
131
|
+
fields = associations.keys
|
132
|
+
unless requested.nil?
|
133
|
+
fields = requested & fields
|
134
|
+
end
|
135
|
+
|
136
|
+
field_set = Set.new(fields)
|
137
|
+
|
138
|
+
included_associations = source.fetchable(associations.keys, @context)
|
139
|
+
associations.each_with_object({}) do |(name, association), hash|
|
140
|
+
if included_associations.include? name
|
141
|
+
key = association.key
|
142
|
+
|
143
|
+
if field_set.include?(name)
|
144
|
+
hash[name] = source.send(key)
|
145
|
+
end
|
146
|
+
|
147
|
+
ia = requested_associations.is_a?(Hash) ? requested_associations[name] : nil
|
148
|
+
|
149
|
+
include_linked_object = ia && ia[:include]
|
150
|
+
include_linked_children = ia && ia[:include_children]
|
151
|
+
|
152
|
+
type = association.serialize_type_name
|
153
|
+
|
154
|
+
# If the object has been serialized once it will be in the related objects list,
|
155
|
+
# but it's possible all children won't have been captured. So we must still go
|
156
|
+
# through the associations.
|
157
|
+
if include_linked_object || include_linked_children
|
158
|
+
if association.is_a?(JSONAPI::Association::HasOne)
|
159
|
+
object = source.send("_#{name}_object")
|
160
|
+
|
161
|
+
id = object.send(association.primary_key)
|
162
|
+
associations_only = already_serialized?(type, id)
|
163
|
+
if include_linked_object && !associations_only
|
164
|
+
add_linked_object(type, id, object_hash(object, ia[:include_related]))
|
165
|
+
elsif include_linked_children || associations_only
|
166
|
+
links_hash(object, ia[:include_related])
|
167
|
+
end
|
168
|
+
elsif association.is_a?(JSONAPI::Association::HasMany)
|
169
|
+
objects = source.send("_#{name}_objects")
|
170
|
+
objects.each do |object|
|
171
|
+
id = object.send(association.primary_key)
|
172
|
+
associations_only = already_serialized?(type, id)
|
173
|
+
if include_linked_object && !associations_only
|
174
|
+
add_linked_object(type, id, object_hash(object, ia[:include_related]))
|
175
|
+
elsif include_linked_children || associations_only
|
176
|
+
links_hash(object, ia[:include_related])
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
def already_serialized?(type, id)
|
186
|
+
return @linked_objects.key?(type) && @linked_objects[type].key?(id)
|
187
|
+
end
|
188
|
+
|
189
|
+
# Sets that an object should be included in the primary document of the response.
|
190
|
+
def set_primary(type, id)
|
191
|
+
@linked_objects[type][id][:primary] = true
|
192
|
+
end
|
193
|
+
|
194
|
+
# Collects the hashes for all objects processed by the serializer
|
195
|
+
def add_linked_object(type, id, object_hash, primary = false)
|
196
|
+
unless @linked_objects.key?(type)
|
197
|
+
@linked_objects[type] = {}
|
198
|
+
end
|
199
|
+
|
200
|
+
if already_serialized?(type, id)
|
201
|
+
if primary
|
202
|
+
set_primary(type, id)
|
203
|
+
end
|
204
|
+
else
|
205
|
+
@linked_objects[type].store(id, {primary: primary, object_hash: object_hash})
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
module ActionDispatch
|
2
|
+
module Routing
|
3
|
+
class Mapper
|
4
|
+
Resources.class_eval do
|
5
|
+
def jsonapi_resource(*resources, &block)
|
6
|
+
resource_type = resources.first
|
7
|
+
options = resources.extract_options!.dup
|
8
|
+
|
9
|
+
res = JSONAPI::Resource.resource_for(resource_type)
|
10
|
+
resource resource_type, options.merge(res.routing_resource_options) do
|
11
|
+
@scope[:jsonapi_resource] = resource_type
|
12
|
+
|
13
|
+
if block_given?
|
14
|
+
yield
|
15
|
+
else
|
16
|
+
res._associations.each do |association_name, association|
|
17
|
+
if association.is_a?(JSONAPI::Association::HasMany)
|
18
|
+
jsonapi_links(association_name)
|
19
|
+
else
|
20
|
+
jsonapi_link(association_name)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def jsonapi_resources(*resources, &block)
|
28
|
+
resource_type = resources.first
|
29
|
+
options = resources.extract_options!.dup
|
30
|
+
|
31
|
+
res = JSONAPI::Resource.resource_for(resource_type)
|
32
|
+
resources resource_type, options.merge(res.routing_resource_options) do
|
33
|
+
@scope[:jsonapi_resource] = resource_type
|
34
|
+
|
35
|
+
if block_given?
|
36
|
+
yield
|
37
|
+
else
|
38
|
+
res._associations.each do |association_name, association|
|
39
|
+
if association.is_a?(JSONAPI::Association::HasMany)
|
40
|
+
jsonapi_links(association_name)
|
41
|
+
else
|
42
|
+
jsonapi_link(association_name)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def links_methods(options)
|
50
|
+
default_methods = [:show, :create, :destroy]
|
51
|
+
if only = options[:only]
|
52
|
+
Array(only).map(&:to_sym)
|
53
|
+
elsif except = options[:except]
|
54
|
+
default_methods - except
|
55
|
+
else
|
56
|
+
default_methods
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def jsonapi_link(*links)
|
61
|
+
link_type = links.first
|
62
|
+
options = links.extract_options!.dup
|
63
|
+
|
64
|
+
res = JSONAPI::Resource.resource_for(@scope[:jsonapi_resource])
|
65
|
+
|
66
|
+
methods = links_methods(options)
|
67
|
+
|
68
|
+
if methods.include?(:show)
|
69
|
+
match "links/#{link_type}", controller: res._type.to_s, action: 'show_association', association: link_type.to_s, via: [:get]
|
70
|
+
end
|
71
|
+
|
72
|
+
if methods.include?(:create)
|
73
|
+
match "links/#{link_type}", controller: res._type.to_s, action: 'create_association', association: link_type.to_s, via: [:post]
|
74
|
+
end
|
75
|
+
|
76
|
+
if methods.include?(:destroy)
|
77
|
+
match "links/#{link_type}", controller: res._type.to_s, action: 'destroy_association', association: link_type.to_s, via: [:delete]
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def jsonapi_links(*links)
|
82
|
+
link_type = links.first
|
83
|
+
options = links.extract_options!.dup
|
84
|
+
|
85
|
+
res = JSONAPI::Resource.resource_for(@scope[:jsonapi_resource])
|
86
|
+
|
87
|
+
methods = links_methods(options)
|
88
|
+
|
89
|
+
if methods.include?(:show)
|
90
|
+
match "links/#{link_type}", controller: res._type.to_s, action: 'show_association', association: link_type.to_s, via: [:get]
|
91
|
+
end
|
92
|
+
|
93
|
+
if methods.include?(:create)
|
94
|
+
match "links/#{link_type}", controller: res._type.to_s, action: 'create_association', association: link_type.to_s, via: [:post]
|
95
|
+
end
|
96
|
+
|
97
|
+
if methods.include?(:destroy)
|
98
|
+
match "links/#{link_type}/:keys", controller: res._type.to_s, action: 'destroy_association', association: link_type.to_s, via: [:delete]
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|