jsonapi-resources 0.0.1

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.
@@ -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,5 @@
1
+ module JSONAPI
2
+ module Resources
3
+ VERSION = "0.0.1"
4
+ end
5
+ 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