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