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,19 @@
1
+ # If you're using Rails + responders gem to get respond_with
2
+ module Graphiti
3
+ module Responders
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ include ActionController::MimeResponds
8
+ respond_to(*Graphiti.config.respond_to)
9
+ end
10
+
11
+ # Override to avoid location url generation (for now)
12
+ def respond_with(*args, &blk)
13
+ opts = args.extract_options!
14
+ opts[:location] = nil
15
+ args << opts
16
+ super(*args, &blk)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,25 @@
1
+ module Graphiti
2
+ class Runner
3
+ attr_reader :params
4
+ include Graphiti::Base
5
+
6
+ def initialize(resource_class, params)
7
+ @resource_class = resource_class
8
+ @params = params
9
+ end
10
+
11
+ def jsonapi_resource
12
+ @jsonapi_resource ||= @resource_class.new
13
+ end
14
+
15
+ # Typically, this is 'self' of a controller
16
+ # We're overriding here so we can do stuff like
17
+ #
18
+ # Graphiti.with_context my_context, {} do
19
+ # Runner.new ...
20
+ # end
21
+ def jsonapi_context
22
+ Graphiti.context[:object]
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,98 @@
1
+ module Graphiti
2
+ class Scope
3
+ attr_accessor :object, :unpaginated_object
4
+
5
+ def initialize(object, resource, query, opts = {})
6
+ @object = object
7
+ @resource = resource
8
+ @query = query
9
+ @opts = opts
10
+
11
+ @object = @resource.around_scoping(@object, query_hash) do |scope|
12
+ apply_scoping(scope, opts)
13
+ end
14
+ end
15
+
16
+ def resolve_stats
17
+ if query_hash[:stats]
18
+ Stats::Payload.new(@resource, @query, @unpaginated_object).generate
19
+ else
20
+ {}
21
+ end
22
+ end
23
+
24
+ def resolve
25
+ if @query.zero_results?
26
+ []
27
+ else
28
+ resolved = @resource.resolve(@object)
29
+ assign_serializer(resolved)
30
+ yield resolved if block_given?
31
+ if @opts[:after_resolve]
32
+ @opts[:after_resolve].call(resolved)
33
+ end
34
+ sideload(resolved) unless @query.sideloads.empty?
35
+ resolved
36
+ end
37
+ end
38
+
39
+ def query_hash
40
+ @query_hash ||= @query.to_hash
41
+ end
42
+
43
+ private
44
+
45
+ # Used to ensure the resource's serializer is used
46
+ # Not one derived through the usual jsonapi-rb logic
47
+ def assign_serializer(records)
48
+ records.each do |r|
49
+ serializer = @resource.serializer_for(r)
50
+ r.instance_variable_set(:@__serializer_klass, serializer)
51
+ end
52
+ end
53
+
54
+ def sideload(results)
55
+ return if results == []
56
+
57
+ concurrent = ::Graphiti.config.concurrency
58
+ promises = []
59
+
60
+ @query.sideloads.each_pair do |name, q|
61
+ sideload = @resource.class.sideload(name)
62
+ resolve_sideload = -> { sideload.resolve(results, q) }
63
+ if concurrent
64
+ promises << Concurrent::Promise.execute(&resolve_sideload)
65
+ else
66
+ resolve_sideload.call
67
+ end
68
+ end
69
+
70
+ if concurrent
71
+ # Wait for all promises to finish
72
+ while !promises.all? { |p| p.fulfilled? || p.rejected? }
73
+ sleep 0.01
74
+ end
75
+ # Re-raise the error with correct stacktrace
76
+ # OPTION** to avoid failing here?? if so need serializable patch
77
+ # to avoid loading data when association not loaded
78
+ if rejected = promises.find(&:rejected?)
79
+ raise rejected.reason
80
+ end
81
+ end
82
+ end
83
+
84
+ def apply_scoping(scope, opts)
85
+ @object = scope
86
+ add_scoping(nil, Graphiti::Scoping::DefaultFilter, opts)
87
+ add_scoping(:filter, Graphiti::Scoping::Filter, opts)
88
+ add_scoping(:sort, Graphiti::Scoping::Sort, opts)
89
+ add_scoping(:paginate, Graphiti::Scoping::Paginate, opts)
90
+ @object
91
+ end
92
+
93
+ def add_scoping(key, scoping_class, opts, default = {})
94
+ @object = scoping_class.new(@resource, query_hash, @object, opts).apply
95
+ @unpaginated_object = @object unless key == :paginate
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,99 @@
1
+ module Graphiti
2
+ module Scoping
3
+ # The interface for scoping logic (filter, paginate, etc).
4
+ #
5
+ # This class defines some common behavior, such as falling back on
6
+ # a default if not part of the user request.
7
+ #
8
+ # @attr_reader [Resource] resource The corresponding Resource instance
9
+ # @attr_reader [Hash] query_hash the Query#to_hash node relevant to the current resource
10
+ #
11
+ # @see Scoping::DefaultFilter
12
+ # @see Scoping::ExtraFields
13
+ # @see Scoping::Filter
14
+ # @see Scoping::Paginate
15
+ # @see Scoping::Sort
16
+ # @see Scope#initialize
17
+ # @see Scope#query_hash
18
+ # @see Query#to_hash
19
+ class Base
20
+ attr_reader :resource, :query_hash
21
+
22
+ # @param [Resource] resource the Resource instance
23
+ # @param [Hash] query_hash the Query#to_hash node relevant to the current resource
24
+ # @param scope the base scope object to chain/modify
25
+ # @param [Hash] opts configuration options used by subclasses
26
+ def initialize(resource, query_hash, scope, opts = {})
27
+ @query_hash = query_hash
28
+ @resource = resource
29
+ @scope = scope
30
+ @opts = opts
31
+ end
32
+
33
+ # Apply this scoping criteria.
34
+ # This is where we would chain on pagination, sorting, etc.
35
+ #
36
+ # If #apply? returns false, does nothing. Otherwise will apply
37
+ # the default logic:
38
+ #
39
+ # # no block, run default logic via adapter
40
+ # allow_filter :name
41
+ #
42
+ # Or the customized proc:
43
+ #
44
+ # allow_filter :name do |scope, value|
45
+ # scope.where("upper(name) = ?", value.upcase)
46
+ # end
47
+ #
48
+ # @see #apply?
49
+ # @return the scope object
50
+ def apply
51
+ if apply?
52
+ apply_standard_or_override
53
+ else
54
+ @scope
55
+ end
56
+ end
57
+
58
+ # Should we process this scope logic?
59
+ #
60
+ # Useful for when we want to explicitly opt-out on
61
+ # certain requests, or avoid a default in certain contexts.
62
+ #
63
+ # @return [Boolean] if we should apply this scope logic
64
+ def apply?
65
+ true
66
+ end
67
+
68
+ # Defines how to call/apply the default scoping logic
69
+ def apply_standard_scope
70
+ raise 'override in subclass'
71
+ end
72
+
73
+ # Defines how to call/apply the custom scoping logic provided by the
74
+ # user.
75
+ def apply_custom_scope
76
+ raise 'override in subclass'
77
+ end
78
+
79
+ private
80
+
81
+ # If the user customized (by providing a block in the Resource DSL)
82
+ # then call the custom proc. Else, call the default proc.
83
+ def apply_standard_or_override
84
+ if apply_standard_scope?
85
+ @scope = apply_standard_scope
86
+ else
87
+ @scope = apply_custom_scope
88
+ end
89
+
90
+ @scope
91
+ end
92
+
93
+ # Should we apply the default proc, or a custom one?
94
+ def apply_standard_scope?
95
+ custom_scope.nil?
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,58 @@
1
+ module Graphiti
2
+ # Default filters apply to every request, unless specifically overridden in
3
+ # the request.
4
+ #
5
+ # Maybe we only want to show active posts:
6
+ #
7
+ # class PostResource < ApplicationResource
8
+ # # ... code ...
9
+ # default_filter :active do |scope|
10
+ # scope.where(active: true)
11
+ # end
12
+ # end
13
+ #
14
+ # But if the user is an admin and specifically requests inactive posts:
15
+ #
16
+ # class PostResource < ApplicationResource
17
+ # # ... code ...
18
+ # allow_filter :active, if: admin?
19
+ #
20
+ # default_filter :active do |scope|
21
+ # scope.where(active: true)
22
+ # end
23
+ # end
24
+ #
25
+ # # Now a GET /posts?filter[active]=false will return inactive posts
26
+ # # if the user is an admin.
27
+ #
28
+ # @see Resource.default_filter
29
+ # @see Resource.allow_filter
30
+ class Scoping::DefaultFilter < Scoping::Base
31
+ include Scoping::Filterable
32
+
33
+ # Apply the default filtering logic.
34
+ # Loop through each defined default filter, and apply the default
35
+ # proc unless an explicit override is requested
36
+ #
37
+ # @return scope the scope object we are chaining/modifying
38
+ def apply
39
+ resource.default_filters.each_pair do |name, opts|
40
+ next if overridden?(name)
41
+ @scope = opts[:filter].call(@scope, resource.context)
42
+ end
43
+
44
+ @scope
45
+ end
46
+
47
+ private
48
+
49
+ def overridden?(name)
50
+ if found = find_filter(name)
51
+ found_aliases = found[name][:aliases]
52
+ filter_param.keys.any? { |k| found_aliases.include?(k.to_sym) }
53
+ else
54
+ false
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,29 @@
1
+ module Graphiti
2
+ class Scoping::ExtraAttributes < Scoping::Base
3
+ # Loop through all requested extra fields. If custom scoping
4
+ # logic is define for that field, run it. Otherwise, do nothing.
5
+ #
6
+ # @return the scope object we are chaining/modofying
7
+ def apply
8
+ each_extra_attribute do |callable|
9
+ @scope = callable.call(@scope, resource.context)
10
+ end
11
+
12
+ @scope
13
+ end
14
+
15
+ private
16
+
17
+ def each_extra_field
18
+ resource.extra_fields.each_pair do |name, callable|
19
+ if extra_fields.include?(name)
20
+ yield callable
21
+ end
22
+ end
23
+ end
24
+
25
+ def extra_fields
26
+ query_hash[:extra_fields][resource.type] || []
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,93 @@
1
+ module Graphiti
2
+ # Apply filtering logic to the scope
3
+ #
4
+ # If the user requests to filter a field that has not been whitelisted,
5
+ # a +Graphiti::Errors::BadFilter+ error will be raised.
6
+ #
7
+ # allow_filter :title # :title now whitelisted
8
+ #
9
+ # If the user requests a filter field that has been whitelisted, but
10
+ # does not pass the associated `+:if+ clause, +BadFilter+ will be raised.
11
+ #
12
+ # allow_filter :title, if: :admin?
13
+ #
14
+ # This will also honor filter aliases.
15
+ #
16
+ # # GET /posts?filter[headline]=foo will filter on title
17
+ # allow_filter :title, aliases: [:headline]
18
+ #
19
+ # @see Adapters::Abstract#filter
20
+ # @see Adapters::ActiveRecord#filter
21
+ # @see Resource.allow_filter
22
+ class Scoping::Filter < Scoping::Base
23
+ include Scoping::Filterable
24
+
25
+ # Apply the filtering logic.
26
+ #
27
+ # Loop and parse all requested filters, taking into account guards and
28
+ # aliases. If valid, call either the default or custom filtering logic.
29
+ # @return the scope we are chaining/modifying
30
+ def apply
31
+ if missing_required_filters.any?
32
+ raise Errors::RequiredFilter.new(resource, missing_required_filters)
33
+ end
34
+
35
+ each_filter do |filter, operator, value|
36
+ @scope = filter_scope(filter, operator, value)
37
+ end
38
+
39
+ @scope
40
+ end
41
+
42
+ private
43
+
44
+ def filter_scope(filter, operator, value)
45
+ operator = operator.to_s.gsub('!', 'not_').to_sym
46
+
47
+ if custom_scope = filter.values.first[operator]
48
+ custom_scope.call(@scope, value, resource.context)
49
+ else
50
+ filter_via_adapter(filter, operator, value)
51
+ end
52
+ end
53
+
54
+ def filter_via_adapter(filter, operator, value)
55
+ type_name = Types.name_for(filter.values.first[:type])
56
+ method = :"filter_#{type_name}_#{operator}"
57
+ attribute = filter.keys.first
58
+
59
+ if resource.adapter.respond_to?(method)
60
+ resource.adapter.send(method, @scope, attribute, value)
61
+ else
62
+ raise Errors::AdapterNotImplemented.new \
63
+ resource.adapter, attribute, method
64
+ end
65
+ end
66
+
67
+ def each_filter
68
+ filter_param.each_pair do |param_name, param_value|
69
+ filter = find_filter!(param_name)
70
+ param_value = { eq: param_value } unless param_value.is_a?(Hash)
71
+ value = param_value.values.first
72
+ operator = param_value.keys.first
73
+ value = param_value.values.first unless filter.values[0][:type] == :hash
74
+ value = value.split(',') if value.is_a?(String) && value.include?(',')
75
+ value = coerce_types(param_name.to_sym, value)
76
+ yield filter, operator, value
77
+ end
78
+ end
79
+
80
+ def coerce_types(name, value)
81
+ type_name = @resource.all_attributes[name][:type]
82
+ is_array = type_name.to_s.starts_with?('array_of') ||
83
+ Types[type_name][:canonical_name] == :array
84
+
85
+ if is_array
86
+ @resource.typecast(name, value, :filterable)
87
+ else
88
+ value = value.nil? || value.is_a?(Hash) ? [value] : Array(value)
89
+ value.map { |v| @resource.typecast(name, v, :filterable) }
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,36 @@
1
+ module Graphiti
2
+ # @api private
3
+ module Scoping::Filterable
4
+ # @api private
5
+ def find_filter(name)
6
+ find_filter!(name)
7
+ rescue Graphiti::Errors::AttributeError
8
+ nil
9
+ end
10
+
11
+ # @api private
12
+ def find_filter!(name)
13
+ resource.class.get_attr!(name, :filterable, request: true)
14
+ { name => resource.filters[name] }
15
+ end
16
+
17
+ # @api private
18
+ def filter_param
19
+ query_hash[:filter] || {}
20
+ end
21
+
22
+ def missing_required_filters
23
+ required_attributes - filter_param.keys
24
+ end
25
+
26
+ def required_attributes
27
+ resource.attributes.map do |k, v|
28
+ k if v[:filterable] == :required
29
+ end.compact
30
+ end
31
+
32
+ def required_filters_provided?
33
+ missing_required_filters.empty?
34
+ end
35
+ end
36
+ end