graphiti-rb 1.0.alpha.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.
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