graphiti-rb 1.0.alpha.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (95) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -0
  3. data/.rspec +3 -0
  4. data/.ruby-version +1 -0
  5. data/.travis.yml +20 -0
  6. data/.yardopts +2 -0
  7. data/Appraisals +11 -0
  8. data/CODE_OF_CONDUCT.md +49 -0
  9. data/Gemfile +12 -0
  10. data/Guardfile +32 -0
  11. data/LICENSE.txt +21 -0
  12. data/README.md +75 -0
  13. data/Rakefile +15 -0
  14. data/bin/appraisal +17 -0
  15. data/bin/console +14 -0
  16. data/bin/rspec +17 -0
  17. data/bin/setup +8 -0
  18. data/gemfiles/rails_4.gemfile +17 -0
  19. data/gemfiles/rails_5.gemfile +17 -0
  20. data/graphiti.gemspec +34 -0
  21. data/lib/generators/jsonapi/resource_generator.rb +169 -0
  22. data/lib/generators/jsonapi/templates/application_resource.rb.erb +15 -0
  23. data/lib/generators/jsonapi/templates/controller.rb.erb +61 -0
  24. data/lib/generators/jsonapi/templates/create_request_spec.rb.erb +30 -0
  25. data/lib/generators/jsonapi/templates/destroy_request_spec.rb.erb +20 -0
  26. data/lib/generators/jsonapi/templates/index_request_spec.rb.erb +22 -0
  27. data/lib/generators/jsonapi/templates/resource.rb.erb +11 -0
  28. data/lib/generators/jsonapi/templates/resource_reads_spec.rb.erb +62 -0
  29. data/lib/generators/jsonapi/templates/resource_writes_spec.rb.erb +63 -0
  30. data/lib/generators/jsonapi/templates/show_request_spec.rb.erb +21 -0
  31. data/lib/generators/jsonapi/templates/update_request_spec.rb.erb +34 -0
  32. data/lib/graphiti-rb.rb +1 -0
  33. data/lib/graphiti.rb +121 -0
  34. data/lib/graphiti/adapters/abstract.rb +516 -0
  35. data/lib/graphiti/adapters/active_record.rb +6 -0
  36. data/lib/graphiti/adapters/active_record/base.rb +249 -0
  37. data/lib/graphiti/adapters/active_record/belongs_to_sideload.rb +17 -0
  38. data/lib/graphiti/adapters/active_record/has_many_sideload.rb +17 -0
  39. data/lib/graphiti/adapters/active_record/has_one_sideload.rb +17 -0
  40. data/lib/graphiti/adapters/active_record/inferrence.rb +12 -0
  41. data/lib/graphiti/adapters/active_record/many_to_many_sideload.rb +30 -0
  42. data/lib/graphiti/adapters/null.rb +236 -0
  43. data/lib/graphiti/base.rb +70 -0
  44. data/lib/graphiti/configuration.rb +21 -0
  45. data/lib/graphiti/context.rb +16 -0
  46. data/lib/graphiti/deserializer.rb +208 -0
  47. data/lib/graphiti/errors.rb +309 -0
  48. data/lib/graphiti/extensions/boolean_attribute.rb +33 -0
  49. data/lib/graphiti/extensions/extra_attribute.rb +70 -0
  50. data/lib/graphiti/extensions/temp_id.rb +26 -0
  51. data/lib/graphiti/filter_operators.rb +25 -0
  52. data/lib/graphiti/hash_renderer.rb +57 -0
  53. data/lib/graphiti/jsonapi_serializable_ext.rb +50 -0
  54. data/lib/graphiti/query.rb +251 -0
  55. data/lib/graphiti/rails.rb +28 -0
  56. data/lib/graphiti/railtie.rb +74 -0
  57. data/lib/graphiti/renderer.rb +60 -0
  58. data/lib/graphiti/resource.rb +110 -0
  59. data/lib/graphiti/resource/configuration.rb +239 -0
  60. data/lib/graphiti/resource/dsl.rb +138 -0
  61. data/lib/graphiti/resource/interface.rb +32 -0
  62. data/lib/graphiti/resource/polymorphism.rb +68 -0
  63. data/lib/graphiti/resource/sideloading.rb +102 -0
  64. data/lib/graphiti/resource_proxy.rb +127 -0
  65. data/lib/graphiti/responders.rb +19 -0
  66. data/lib/graphiti/runner.rb +25 -0
  67. data/lib/graphiti/scope.rb +98 -0
  68. data/lib/graphiti/scoping/base.rb +99 -0
  69. data/lib/graphiti/scoping/default_filter.rb +58 -0
  70. data/lib/graphiti/scoping/extra_attributes.rb +29 -0
  71. data/lib/graphiti/scoping/filter.rb +93 -0
  72. data/lib/graphiti/scoping/filterable.rb +36 -0
  73. data/lib/graphiti/scoping/paginate.rb +87 -0
  74. data/lib/graphiti/scoping/sort.rb +64 -0
  75. data/lib/graphiti/sideload.rb +281 -0
  76. data/lib/graphiti/sideload/belongs_to.rb +34 -0
  77. data/lib/graphiti/sideload/has_many.rb +16 -0
  78. data/lib/graphiti/sideload/has_one.rb +9 -0
  79. data/lib/graphiti/sideload/many_to_many.rb +24 -0
  80. data/lib/graphiti/sideload/polymorphic_belongs_to.rb +108 -0
  81. data/lib/graphiti/stats/dsl.rb +89 -0
  82. data/lib/graphiti/stats/payload.rb +49 -0
  83. data/lib/graphiti/types.rb +172 -0
  84. data/lib/graphiti/util/attribute_check.rb +88 -0
  85. data/lib/graphiti/util/field_params.rb +16 -0
  86. data/lib/graphiti/util/hash.rb +51 -0
  87. data/lib/graphiti/util/hooks.rb +33 -0
  88. data/lib/graphiti/util/include_params.rb +39 -0
  89. data/lib/graphiti/util/persistence.rb +219 -0
  90. data/lib/graphiti/util/relationship_payload.rb +64 -0
  91. data/lib/graphiti/util/serializer_attributes.rb +97 -0
  92. data/lib/graphiti/util/sideload.rb +33 -0
  93. data/lib/graphiti/util/validation_response.rb +78 -0
  94. data/lib/graphiti/version.rb +3 -0
  95. metadata +317 -0
@@ -0,0 +1,33 @@
1
+ module Graphiti
2
+ module Extensions
3
+ # Turns ruby ? methods into is_ attributes
4
+ #
5
+ # @example Basic Usage
6
+ # boolean_attribute :active?
7
+ #
8
+ # # equivalent do
9
+ # def is_active
10
+ # @object.active?
11
+ # end
12
+ module BooleanAttribute
13
+ def self.included(klass)
14
+ klass.extend ClassMethods
15
+ end
16
+
17
+ module ClassMethods
18
+ # Register a boolean attribute
19
+ # @param name the corresponding ? method
20
+ # @param [Hash] options Normal .attribute options
21
+ def boolean_attribute(name, options = {}, &blk)
22
+ blk ||= proc { @object.public_send(name) }
23
+ field_name = :"is_#{name.to_s.gsub('?', '')}"
24
+ attribute field_name, options, &blk
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ JSONAPI::Serializable::Resource.class_eval do
32
+ include Graphiti::Extensions::BooleanAttribute
33
+ end
@@ -0,0 +1,70 @@
1
+ require 'jsonapi/serializable/resource/conditional_fields'
2
+
3
+ module Graphiti
4
+ module Extensions
5
+ # Only render a given attribute when the user specifically requests it.
6
+ # Useful for computationally-expensive attributes that are not required
7
+ # on every request.
8
+ #
9
+ # This class handles the serialization, but you may also want to run
10
+ # code during scoping (for instance, to eager load associations referenced
11
+ # by this extra attribute. See (Resource.extra_field).
12
+ #
13
+ # @example Basic Usage
14
+ # # Will only be rendered on user request, ie
15
+ # # /people?extra_fields[people]=net_worth
16
+ # extra_attribute :net_worth
17
+ #
18
+ # @example Eager Loading
19
+ # class PersonResource < ApplicationResource
20
+ # # If the user requests the 'net_worth' attribute, make sure
21
+ # # 'assets' are eager loaded
22
+ # extra_field :net_worth do |scope|
23
+ # scope.includes(:assets)
24
+ # end
25
+ # end
26
+ #
27
+ # class SerializablePerson < JSONAPI::Serializable::Resource
28
+ # # ... code ...
29
+ # extra_attribute :net_worth do
30
+ # @object.assets.sum(&:value)
31
+ # end
32
+ # end
33
+ #
34
+ # @see Resource.extra_field
35
+ module ExtraAttribute
36
+ def self.included(klass)
37
+ klass.extend ClassMethods
38
+ end
39
+
40
+ module ClassMethods
41
+ # @param [Symbol] name the name of the attribute
42
+ # @param [Hash] options the options passed on to vanilla to .attribute
43
+ def extra_attribute(name, options = {}, &blk)
44
+ allow_field = proc {
45
+ if options[:if]
46
+ next false unless instance_eval(&options[:if])
47
+ end
48
+
49
+ @extra_fields &&
50
+ @extra_fields[@_type] &&
51
+ @extra_fields[@_type].include?(name)
52
+ }
53
+
54
+ attribute name, if: allow_field, &blk
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ JSONAPI::Serializable::Resource.class_eval do
62
+ def self.inherited(klass)
63
+ super
64
+ klass.class_eval do
65
+ extend JSONAPI::Serializable::Resource::ConditionalFields
66
+ end
67
+ end
68
+
69
+ include Graphiti::Extensions::ExtraAttribute
70
+ end
@@ -0,0 +1,26 @@
1
+ module Graphiti
2
+ # If the object we are serializing has the instance variable
3
+ # +@_jsonapi_temp_id+, render +temp-id+ in the {http://jsonapi.org/format/#document-resource-identifier-objects resource identifier}
4
+ #
5
+ # Why? Well, when the request is a nested POST, creating the main entity as
6
+ # well as relationships, we need some way of telling the client, "hey, the
7
+ # object you have in memory, that you just sent to the server, has been
8
+ # persisted and now has id X".
9
+ #
10
+ # +@_jsonapi_temp_id+ is set within this library. You should never have to
11
+ # reference it directly.
12
+ module SerializableTempId
13
+ # Common interface for jsonapi-rb extensions
14
+ def as_jsonapi(*)
15
+ super.tap do |hash|
16
+ if temp_id = @object.instance_variable_get(:'@_jsonapi_temp_id')
17
+ hash[:'temp-id'] = temp_id
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ JSONAPI::Serializable::Resource.class_eval do
25
+ prepend Graphiti::SerializableTempId
26
+ end
@@ -0,0 +1,25 @@
1
+ module Graphiti
2
+ class FilterOperators
3
+ class Catchall
4
+ attr_reader :procs
5
+
6
+ def initialize
7
+ @procs = {}
8
+ end
9
+
10
+ def method_missing(name, *args, &blk)
11
+ @procs[name] = blk
12
+ end
13
+
14
+ def to_hash
15
+ @procs
16
+ end
17
+ end
18
+
19
+ def self.build(&blk)
20
+ c = Catchall.new
21
+ c.instance_eval(&blk) if blk
22
+ c.to_hash
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,57 @@
1
+ module Graphiti
2
+ module SerializableHash
3
+ def to_hash(fields: nil, include: {})
4
+ {}.tap do |hash|
5
+ _fields = fields[jsonapi_type] if fields
6
+ attrs = requested_attributes(_fields).each_with_object({}) do |(k, v), h|
7
+ h[k] = instance_eval(&v)
8
+ end
9
+ rels = @_relationships.select { |k,v| !!include[k] }
10
+ rels.each_with_object({}) do |(k, v), h|
11
+ serializers = v.send(:resources)
12
+ attrs[k] = if serializers.is_a?(Array)
13
+ serializers.map do |rr| # use private method to avoid array casting
14
+ rr.to_hash(fields: fields, include: include[k])
15
+ end
16
+ elsif serializers.nil?
17
+ nil
18
+ else
19
+ serializers.to_hash(fields: fields, include: include[k])
20
+ end
21
+ end
22
+
23
+ hash[:id] = jsonapi_id
24
+ hash.merge!(attrs) if attrs.any?
25
+ end
26
+ end
27
+ end
28
+ JSONAPI::Serializable::Resource.send(:include, SerializableHash)
29
+
30
+ class HashRenderer
31
+ def initialize(resource)
32
+ @resource = resource
33
+ end
34
+
35
+ def render(options)
36
+ serializers = options[:data]
37
+ opts = options.slice(:fields, :include)
38
+ to_hash(serializers, opts).tap do |hash|
39
+ hash.merge!(options.slice(:meta)) if !options[:meta].empty?
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def to_hash(serializers, opts)
46
+ {}.tap do |hash|
47
+ if serializers.is_a?(Array)
48
+ hash[@resource.type] = serializers.map do |s|
49
+ s.to_hash(opts)
50
+ end
51
+ else
52
+ hash[@resource.type] = serializers.to_hash(opts)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,50 @@
1
+ module Graphiti
2
+ module JsonapiSerializableExt
3
+ # This library looks up a serializer based on the record's class name
4
+ # This wouldn't work for us, since a model may be associated with
5
+ # multiple resources.
6
+ # Instead, this variable is assigned when the query is resolved
7
+ # To ensure we always render with the *resource* serializer
8
+ module RendererOverrides
9
+ def _build(object, exposures, klass)
10
+ klass = object.instance_variable_get(:@__serializer_klass)
11
+ klass.new(exposures.merge(object: object))
12
+ end
13
+ end
14
+
15
+ # See above comment
16
+ module RelationshipOverrides
17
+ def data
18
+ @_resources_block = proc do
19
+ resources = yield
20
+ if resources.nil?
21
+ nil
22
+ elsif resources.respond_to?(:to_ary)
23
+ Array(resources).map do |obj|
24
+ klass = obj.instance_variable_get(:@__serializer_klass)
25
+ klass.new(@_exposures.merge(object: obj))
26
+ end
27
+ else
28
+ klass = resources.instance_variable_get(:@__serializer_klass)
29
+ klass.new(@_exposures.merge(object: resources))
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ # Temporary fix until fixed upstream
36
+ # https://github.com/jsonapi-rb/jsonapi-serializable/pull/102
37
+ module ResourceOverrides
38
+ def requested_relationships(fields)
39
+ @_relationships
40
+ end
41
+ end
42
+
43
+ JSONAPI::Serializable::Resource
44
+ .send(:prepend, ResourceOverrides)
45
+ JSONAPI::Serializable::Relationship
46
+ .send(:prepend, RelationshipOverrides)
47
+ JSONAPI::Serializable::Renderer
48
+ .send(:prepend, RendererOverrides)
49
+ end
50
+ end
@@ -0,0 +1,251 @@
1
+ module Graphiti
2
+ class Query
3
+ attr_reader :resource, :include_hash, :association_name
4
+
5
+ def initialize(resource, params, association_name = nil, nested_include = nil, parents = [])
6
+ @resource = resource
7
+ @association_name = association_name
8
+ @params = params
9
+ @params = @params.permit! if @params.respond_to?(:permit!)
10
+ @params = @params.to_h if @params.respond_to?(:to_h)
11
+ @params = @params.deep_symbolize_keys
12
+ @include_param = nested_include || @params[:include]
13
+ @parents = parents
14
+ end
15
+
16
+ def association?
17
+ !!@association_name
18
+ end
19
+
20
+ def top_level?
21
+ not association?
22
+ end
23
+
24
+ def to_hash
25
+ {}.tap do |hash|
26
+ hash[:filter] = filters unless filters.empty?
27
+ hash[:sort] = sorts unless sorts.empty?
28
+ hash[:page] = pagination unless pagination.empty?
29
+ unless association?
30
+ hash[:fields] = fields unless fields.empty?
31
+ hash[:extra_fields] = extra_fields unless extra_fields.empty?
32
+ end
33
+ hash[:stats] = stats unless stats.empty?
34
+ hash[:include] = sideload_hash unless sideload_hash.empty?
35
+ end
36
+ end
37
+
38
+ def zero_results?
39
+ !@params[:page].nil? &&
40
+ !@params[:page][:size].nil? &&
41
+ @params[:page][:size].to_i == 0
42
+ end
43
+
44
+ def sideload_hash
45
+ @sideload_hash = begin
46
+ {}.tap do |hash|
47
+ sideloads.each_pair do |key, value|
48
+ hash[key] = sideloads[key].to_hash
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ def sideloads
55
+ @sideloads ||= begin
56
+ {}.tap do |hash|
57
+ include_hash.each_pair do |key, sub_hash|
58
+ sideload = @resource.class.sideload(key)
59
+ if sideload
60
+ _parents = parents + [self]
61
+ hash[key] = Query.new(sideload.resource, @params, key, sub_hash, _parents)
62
+ else
63
+ handle_missing_sideload(key)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ def parents
71
+ @parents ||= []
72
+ end
73
+
74
+ def fields
75
+ @fields ||= begin
76
+ hash = parse_fieldset(@params[:fields] || {})
77
+ hash.each_pair do |type, fields|
78
+ hash[type] += extra_fields[type] if extra_fields[type]
79
+ end
80
+ hash
81
+ end
82
+ end
83
+
84
+ def extra_fields
85
+ @extra_fields ||= parse_fieldset(@params[:extra_fields] || {})
86
+ end
87
+
88
+ def filters
89
+ @filters ||= begin
90
+ {}.tap do |hash|
91
+ (@params[:filter] || {}).each_pair do |name, value|
92
+ name = name.to_sym
93
+
94
+ if legacy_nested?(name)
95
+ filter_name = value.keys.first.to_sym
96
+ filter_value = value.values.first
97
+ if @resource.get_attr!(filter_name, :filterable, request: true)
98
+ hash[filter_name] = filter_value
99
+ end
100
+ elsif nested?(name)
101
+ name = name.to_s.split('.').last.to_sym
102
+ validate!(name, :filterable)
103
+ hash[name] = value
104
+ elsif top_level? && validate!(name, :filterable)
105
+ hash[name] = value
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+ def sorts
113
+ @sorts ||= begin
114
+ return @params[:sort] if @params[:sort].is_a?(Array)
115
+ return [] if @params[:sort].nil?
116
+
117
+ [].tap do |arr|
118
+ sort_hashes do |key, value, type|
119
+ if legacy_nested?(type)
120
+ @resource.get_attr!(key, :sortable, request: true)
121
+ arr << { key => value }
122
+ elsif !type && top_level? && validate!(key, :sortable)
123
+ arr << { key => value }
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ def pagination
131
+ @pagination ||= begin
132
+ {}.tap do |hash|
133
+ (@params[:page] || {}).each_pair do |name, value|
134
+ if legacy_nested?(name)
135
+ value.each_pair do |k,v|
136
+ hash[k.to_sym] = v.to_i
137
+ end
138
+ elsif top_level? && [:number, :size].include?(name.to_sym)
139
+ hash[name.to_sym] = value.to_i
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+
146
+ def include_hash
147
+ @include_hash ||= begin
148
+ requested = include_directive.to_hash
149
+
150
+ whitelist = nil
151
+ if @resource.context && @resource.context.respond_to?(:sideload_whitelist)
152
+ whitelist = @resource.context.sideload_whitelist
153
+ whitelist = whitelist[@resource.context_namespace] if whitelist
154
+ end
155
+
156
+ whitelist ? Util::IncludeParams.scrub(requested, whitelist) : requested
157
+ end
158
+ end
159
+
160
+ def stats
161
+ @stats ||= begin
162
+ {}.tap do |hash|
163
+ (@params[:stats] || {}).each_pair do |k, v|
164
+ if legacy_nested?(k)
165
+ raise NotImplementedError.new('Association statistics are not currently supported')
166
+ elsif top_level?
167
+ v = v.split(',') if v.is_a?(String)
168
+ hash[k.to_sym] = Array(v).flatten.map(&:to_sym)
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
174
+
175
+ private
176
+
177
+ def validate!(name, flag)
178
+ return false if name.to_s.include?('.') # nested
179
+
180
+ not_associated_name = !@resource.class.association_names.include?(name)
181
+ not_associated_type = !@resource.class.association_types.include?(name)
182
+
183
+ if not_associated_name && not_associated_type
184
+ @resource.get_attr!(name, flag, request: true)
185
+ return true
186
+ end
187
+ false
188
+ end
189
+
190
+ def nested?(name)
191
+ return false unless association?
192
+ split = name.to_s.split('.')
193
+ query_names = split[0..split.length-2].map(&:to_sym)
194
+ my_names = parents.map(&:association_name).compact + [association_name].compact
195
+ query_names == my_names
196
+ end
197
+
198
+ def legacy_nested?(name)
199
+ association? &&
200
+ (name == @resource.type || name == @association_name)
201
+ end
202
+
203
+ def parse_fieldset(fieldset)
204
+ {}.tap do |hash|
205
+ fieldset.each_pair do |type, fields|
206
+ type = type.to_sym
207
+ fields = fields.split(',') unless fields.is_a?(Array)
208
+ hash[type] = fields.map(&:to_sym)
209
+ end
210
+ end
211
+ end
212
+
213
+ def include_directive
214
+ @include_directive ||= JSONAPI::IncludeDirective.new(@include_param)
215
+ end
216
+
217
+ def handle_missing_sideload(name)
218
+ if Graphiti.config.raise_on_missing_sideload
219
+ raise Graphiti::Errors::InvalidInclude
220
+ .new(name, @resource.type)
221
+ end
222
+ end
223
+
224
+ def sort_hash(attr)
225
+ value = attr[0] == '-' ? :desc : :asc
226
+ key = attr.sub('-', '').to_sym
227
+
228
+ { key => value }
229
+ end
230
+
231
+ def sort_hashes
232
+ sorts = @params[:sort].split(',')
233
+ sorts.each do |s|
234
+ type, attr = s.split('.')
235
+
236
+ if attr.nil? # top-level
237
+ next if @association_name
238
+ hash = sort_hash(type)
239
+ yield hash.keys.first.to_sym, hash.values.first
240
+ else
241
+ if type[0] == '-'
242
+ type = type.sub('-', '')
243
+ attr = "-#{attr}"
244
+ end
245
+ hash = sort_hash(attr)
246
+ yield hash.keys.first.to_sym, hash.values.first, type.to_sym
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end