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