lighthouse-graphql 0.1.1 → 0.2.0

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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +154 -1
  3. data/README.md +42 -2
  4. data/docs/authorization.md +116 -57
  5. data/docs/best-practices.md +16 -3
  6. data/docs/caching.md +161 -0
  7. data/docs/configuration.md +83 -4
  8. data/docs/custom-directives.md +5 -1
  9. data/docs/directives.md +117 -38
  10. data/docs/getting-started.md +84 -0
  11. data/docs/mutations.md +260 -0
  12. data/docs/performance.md +138 -0
  13. data/docs/relationships.md +80 -33
  14. data/docs/resolvers.md +179 -0
  15. data/docs/validation.md +132 -0
  16. data/lib/generators/lighthouse/builder_generator.rb +30 -0
  17. data/lib/generators/lighthouse/class_generator.rb +61 -0
  18. data/lib/generators/lighthouse/install/install_generator.rb +127 -0
  19. data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +8 -0
  20. data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +57 -0
  21. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +67 -0
  22. data/lib/generators/lighthouse/install/templates/schema.graphql.tt +19 -0
  23. data/lib/generators/lighthouse/install/templates/schema.rb.tt +10 -0
  24. data/lib/generators/lighthouse/mutation_generator.rb +30 -0
  25. data/lib/generators/lighthouse/query_generator.rb +29 -0
  26. data/lib/generators/lighthouse/validator_generator.rb +36 -0
  27. data/lib/lighthouse/cache_control.rb +126 -0
  28. data/lib/lighthouse/caching.rb +68 -0
  29. data/lib/lighthouse/config.rb +85 -19
  30. data/lib/lighthouse/contracts/arg_builder.rb +8 -12
  31. data/lib/lighthouse/contracts/arg_manipulator.rb +8 -12
  32. data/lib/lighthouse/contracts/directive.rb +10 -14
  33. data/lib/lighthouse/contracts/field_manipulator.rb +8 -12
  34. data/lib/lighthouse/contracts/field_middleware.rb +7 -11
  35. data/lib/lighthouse/contracts/field_resolver.rb +9 -13
  36. data/lib/lighthouse/dataloader_sources/association_loader.rb +28 -19
  37. data/lib/lighthouse/dataloader_sources/model_loader.rb +10 -14
  38. data/lib/lighthouse/dataloader_sources/relation_count_loader.rb +16 -0
  39. data/lib/lighthouse/dataloader_sources/relation_page_loader.rb +18 -0
  40. data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +57 -69
  41. data/lib/lighthouse/directive_resolvers/all_resolver.rb +26 -57
  42. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +161 -0
  43. data/lib/lighthouse/directive_resolvers/auth_resolver.rb +41 -38
  44. data/lib/lighthouse/directive_resolvers/base.rb +142 -86
  45. data/lib/lighthouse/directive_resolvers/builder_applier.rb +58 -56
  46. data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +133 -0
  47. data/lib/lighthouse/directive_resolvers/can_resolvers.rb +177 -0
  48. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +70 -0
  49. data/lib/lighthouse/directive_resolvers/count_resolver.rb +42 -48
  50. data/lib/lighthouse/directive_resolvers/field_resolver.rb +27 -68
  51. data/lib/lighthouse/directive_resolvers/find_resolver.rb +42 -52
  52. data/lib/lighthouse/directive_resolvers/first_resolver.rb +39 -45
  53. data/lib/lighthouse/directive_resolvers/guard_resolver.rb +16 -22
  54. data/lib/lighthouse/directive_resolvers/method_resolver.rb +19 -25
  55. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +219 -0
  56. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +70 -128
  57. data/lib/lighthouse/directive_resolvers/rename_resolver.rb +18 -24
  58. data/lib/lighthouse/directive_resolvers/resolution.rb +31 -27
  59. data/lib/lighthouse/directive_resolvers/validator_resolver.rb +193 -0
  60. data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +164 -163
  61. data/lib/lighthouse/directives/arguments/base.rb +23 -31
  62. data/lib/lighthouse/directives/arguments/filters.rb +113 -93
  63. data/lib/lighthouse/directives/arguments/order_by.rb +41 -41
  64. data/lib/lighthouse/directives/pagination_info_types.rb +69 -0
  65. data/lib/lighthouse/directives/pagination_manipulator.rb +176 -0
  66. data/lib/lighthouse/directives/registry.rb +210 -141
  67. data/lib/lighthouse/directives/where_conditions_directive.rb +236 -200
  68. data/lib/lighthouse/errors.rb +27 -0
  69. data/lib/lighthouse/federation_directives.rb +53 -0
  70. data/lib/lighthouse/field_resolver_stack.rb +56 -0
  71. data/lib/lighthouse/generated_resolvers.rb +82 -0
  72. data/lib/lighthouse/graphql/version.rb +1 -1
  73. data/lib/lighthouse/graphql_ruby_patches.rb +18 -0
  74. data/lib/lighthouse/ide_helper.rb +98 -0
  75. data/lib/lighthouse/namespaces.rb +21 -0
  76. data/lib/lighthouse/nested_mutations/belongs_to_operations.rb +50 -0
  77. data/lib/lighthouse/nested_mutations/executor.rb +149 -0
  78. data/lib/lighthouse/nested_mutations/has_operations.rb +66 -0
  79. data/lib/lighthouse/nested_mutations/many_to_many_operations.rb +44 -0
  80. data/lib/lighthouse/nested_mutations/partitioner.rb +125 -0
  81. data/lib/lighthouse/nested_mutations.rb +210 -0
  82. data/lib/lighthouse/pagination.rb +221 -0
  83. data/lib/lighthouse/persisted_queries.rb +167 -0
  84. data/lib/lighthouse/railtie.rb +26 -0
  85. data/lib/lighthouse/rb_lighthouse.rb +50 -29
  86. data/lib/lighthouse/reference_resolver.rb +13 -15
  87. data/lib/lighthouse/relation_batch.rb +156 -0
  88. data/lib/lighthouse/resolver_dispatch.rb +26 -0
  89. data/lib/lighthouse/schema_cache.rb +120 -0
  90. data/lib/lighthouse/schema_factory.rb +27 -37
  91. data/lib/lighthouse/schema_generator.rb +196 -239
  92. data/lib/lighthouse/schema_implementation.rb +51 -48
  93. data/lib/lighthouse/sdl_loader.rb +17 -20
  94. data/lib/lighthouse/support/arguments.rb +119 -0
  95. data/lib/lighthouse/support/authorization.rb +18 -19
  96. data/lib/lighthouse/support/batching.rb +16 -0
  97. data/lib/lighthouse/support/directive_args.rb +26 -24
  98. data/lib/lighthouse/support/model_resolver.rb +56 -56
  99. data/lib/lighthouse/support/naming.rb +55 -59
  100. data/lib/lighthouse/tasks/lighthouse.rake +33 -0
  101. data/lib/lighthouse/where_conditions/operator_map.rb +25 -29
  102. metadata +51 -63
  103. data/lib/lighthouse/directive_resolvers/belongs_to_many_resolver.rb +0 -56
  104. data/lib/lighthouse/directive_resolvers/belongs_to_resolver.rb +0 -47
  105. data/lib/lighthouse/directive_resolvers/can_resolver.rb +0 -86
  106. data/lib/lighthouse/directive_resolvers/has_many_resolver.rb +0 -109
  107. data/lib/lighthouse/directive_resolvers/has_one_resolver.rb +0 -43
  108. data/lib/lighthouse/directives/relation_directive.rb +0 -332
@@ -1,91 +1,147 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Lighthouse
4
- module GraphQL
5
- module DirectiveResolvers
6
- class BaseDirectiveResolver
7
- def initialize(field_def, type_def, directive_node, opts = {})
8
- @field_def = field_def
9
- @type_def = type_def
10
- @directive_node = directive_node
11
- @opts = opts
12
- end
13
-
14
- def define_resolver(target_type_class)
15
- raise NotImplementedError
16
- end
17
-
18
- def apply_where_filters(relation, args)
19
- return relation unless relation.respond_to?(:where)
20
-
21
- args_h = args.respond_to?(:to_h) ? args.to_h : (args || {})
22
-
23
- where_condition = args_h[:where] || args_h['where']
24
- relation = DirectiveResolvers::WhereConditionsApplier.apply(relation, where_condition) if where_condition
25
-
26
- @field_def.ast_node.arguments.each do |arg_node|
27
- next unless arg_node.directives.any? { |dir| dir.name == 'whereHasConditions' }
28
-
29
- arg_value = Lighthouse::Support::Naming.fetch_arg(args_h, arg_node.name)
30
- next unless arg_value
31
-
32
- relation_name = extract_relation_name_from_directive(arg_node) || Lighthouse::Support::Naming.derive_relation_name(arg_node.name)
33
- relation = DirectiveResolvers::WhereConditionsApplier.apply_has_argument(relation, arg_node.name, arg_value, relation_name)
34
- end
35
-
36
- apply_arg_builders(relation, args_h)
37
- rescue ::GraphQL::ExecutionError
38
- raise
39
- rescue StandardError => e
40
- Lighthouse::Config.handle_error(e)
41
- relation
42
- end
43
-
44
- # Compose argument-builder directives (@eq, @neq, @in, @notIn, @like,
45
- # @where, @orderBy) onto the relation, in SDL argument order, for each
46
- # field argument that carries one and that the client supplied a value
47
- # for.
48
- def apply_arg_builders(relation, args_h)
49
- return relation unless relation.respond_to?(:where)
50
- return relation unless @field_def.ast_node
51
-
52
- registry = Lighthouse::DirectiveRegistry.default
53
-
54
- (@field_def.ast_node.arguments || []).each do |arg_node|
55
- (arg_node.directives || []).each do |dnode|
56
- builder_class = registry.arg_builder_class(dnode.name)
57
- next unless builder_class
58
-
59
- value = Lighthouse::Support::Naming.fetch_arg(args_h, arg_node.name)
60
- next if value.nil?
61
-
62
- relation = builder_class.new(arg_node, dnode).handle_builder(relation, value)
63
- end
64
- end
65
-
66
- relation
67
- rescue ::GraphQL::ExecutionError
68
- raise
69
- rescue StandardError => e
70
- Lighthouse::Config.handle_error(e)
71
- relation
72
- end
73
-
74
- def extract_relation_name_from_directive(arg_node)
75
- directive = arg_node.directives.find { |dir| dir.name == 'whereHasConditions' }
76
- return nil unless directive
77
-
78
- relation_arg = directive.arguments.find { |arg| arg.name == 'relation' }
79
- return nil unless relation_arg
80
-
81
- relation_arg.value.respond_to?(:value) ? relation_arg.value.value : relation_arg.value
82
- rescue StandardError
83
- nil
84
- end
85
-
86
- def constantize_model(name)
87
- Lighthouse::Support::ModelResolver.constantize(name)
88
- end
3
+ class Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
4
+ def initialize(field_def, type_def, directive_node, opts = {})
5
+ @field_def = field_def
6
+ @type_def = type_def
7
+ @directive_node = directive_node
8
+ @opts = opts
9
+ end
10
+
11
+ def define_resolver(target_type_class)
12
+ raise NotImplementedError
13
+ end
14
+
15
+ def apply_where_filters(relation, args)
16
+ return relation unless relation.respond_to?(:where)
17
+
18
+ args_h = args.respond_to?(:to_h) ? args.to_h : (args || {})
19
+
20
+ relation = apply_where_conditions(relation, args_h)
21
+ @field_def.ast_node.arguments.each do |arg_node|
22
+ relation = apply_where_has_conditions(relation, arg_node, args_h)
23
+ end
24
+
25
+ apply_arg_builders(relation, args_h)
26
+ rescue ::GraphQL::ExecutionError
27
+ raise
28
+ rescue StandardError => e
29
+ Lighthouse::Config.handle_error(e)
30
+ relation
31
+ end
32
+
33
+ # Compose argument-builder directives (@eq, @neq, @in, @notIn, @like,
34
+ # @where, @orderBy) onto the relation, in SDL argument order, for each
35
+ # field argument that carries one and that the client supplied a value
36
+ # for.
37
+ def apply_arg_builders(relation, args_h)
38
+ return relation unless relation.respond_to?(:where)
39
+ return relation unless @field_def.ast_node
40
+
41
+ arg_builders.each do |arg_node, dnode, builder_class|
42
+ value = Lighthouse::Support::Naming.fetch_arg(args_h, arg_node.name)
43
+ next if value.nil?
44
+
45
+ relation = builder_class.new(arg_node, dnode).handle_builder(relation, value)
46
+ end
47
+
48
+ relation
49
+ rescue ::GraphQL::ExecutionError
50
+ raise
51
+ rescue StandardError => e
52
+ Lighthouse::Config.handle_error(e)
53
+ relation
54
+ end
55
+
56
+ def extract_relation_name_from_directive(arg_node)
57
+ directive = arg_node.directives.find { |dir| dir.name == 'whereHasConditions' }
58
+ return nil unless directive
59
+
60
+ relation_arg = directive.arguments.find { |arg| arg.name == 'relation' }
61
+ return nil unless relation_arg
62
+
63
+ relation_arg.value.respond_to?(:value) ? relation_arg.value.value : relation_arg.value
64
+ rescue StandardError
65
+ nil
66
+ end
67
+
68
+ def constantize_model(name)
69
+ Lighthouse::Support::ModelResolver.constantize(name)
70
+ end
71
+
72
+ # The model scopes the directive's `scopes:` argument names, in order.
73
+ def apply_scopes(relation)
74
+ Array(directive_arg('scopes')).reduce(relation) { |acc, scope| acc.public_send(scope) }
75
+ end
76
+
77
+ protected
78
+
79
+ def directive_arg(name)
80
+ Lighthouse::Support::DirectiveArgs.literal(@directive_node, name)
81
+ end
82
+
83
+ def resolver_method_name
84
+ "resolve_field_#{@field_def.name}"
85
+ end
86
+
87
+ # The field's resolver so far, for a directive that wraps it.
88
+ def capture_inner(target_type_class)
89
+ Lighthouse::GraphQL::DirectiveResolvers::Resolution.capture(target_type_class, resolver_method_name, @field_def, @field_def.name)
90
+ end
91
+
92
+ def install(target_type_class, callable)
93
+ target_type_class.define_singleton_method(resolver_method_name, &callable)
94
+ @field_def.resolve_proc = callable if @field_def.respond_to?(:resolve_proc=)
95
+ end
96
+
97
+ # The model a directive acts on: its `model:` argument, the `model:` of
98
+ # another directive on the field (`@count(model: "Post") @canQuery`), or
99
+ # the field's return type without a generated Paginator/Connection suffix.
100
+ def model_class
101
+ name = directive_arg('model') || sibling_model_name || return_type_model_name
102
+ constantize_model(name) || raise(Lighthouse::DefinitionError,
103
+ "@#{@directive_node&.name} on #{@type_def.graphql_name}.#{@field_def.name}: no model #{name}")
104
+ end
105
+
106
+ private
107
+
108
+ def sibling_model_name
109
+ (@field_def.ast_node&.directives || []).filter_map do |d|
110
+ Lighthouse::Support::DirectiveArgs.literal(d, 'model')
111
+ end.first
112
+ end
113
+
114
+ def return_type_model_name
115
+ @field_def.type.unwrap.graphql_name.sub(/(SimplePaginator|Paginator|Connection)\z/, '')
116
+ end
117
+
118
+ # The `where:` argument (@whereConditions), when the client supplied one.
119
+ def apply_where_conditions(relation, args_h)
120
+ where_condition = args_h[:where] || args_h['where']
121
+ return relation unless where_condition
122
+
123
+ Lighthouse::GraphQL::DirectiveResolvers::WhereConditionsApplier.apply(relation, where_condition)
124
+ end
125
+
126
+ # A `whereHasConditions` argument the client supplied filters the
127
+ # relation by the related records matching its condition.
128
+ def apply_where_has_conditions(relation, arg_node, args_h)
129
+ return relation unless arg_node.directives.any? { |dir| dir.name == 'whereHasConditions' }
130
+
131
+ arg_value = Lighthouse::Support::Naming.fetch_arg(args_h, arg_node.name)
132
+ return relation unless arg_value
133
+
134
+ relation_name = extract_relation_name_from_directive(arg_node) || Lighthouse::Support::Naming.derive_relation_name(arg_node.name)
135
+ Lighthouse::GraphQL::DirectiveResolvers::WhereConditionsApplier.apply_has_argument(relation, arg_node.name, arg_value, relation_name)
136
+ end
137
+
138
+ # Every (argument, directive, builder class) on the field, in SDL order.
139
+ def arg_builders
140
+ registry = Lighthouse::DirectiveRegistry.default
141
+ (@field_def.ast_node.arguments || []).flat_map do |arg_node|
142
+ (arg_node.directives || []).filter_map do |dnode|
143
+ builder_class = registry.arg_builder_class(dnode.name)
144
+ [arg_node, dnode, builder_class] if builder_class
89
145
  end
90
146
  end
91
147
  end
@@ -2,62 +2,64 @@
2
2
 
3
3
  require_relative 'base'
4
4
 
5
- module Lighthouse
6
- module GraphQL
7
- module DirectiveResolvers
8
- class BuilderApplier
9
- def self.apply(relation, field_def_ast_node, args, ctx, resolver_instance)
10
- return relation unless field_def_ast_node && field_def_ast_node.directives
11
-
12
- builder_directives = field_def_ast_node.directives.select { |d| d.name == 'builder' }
13
- return relation if builder_directives.empty?
14
-
15
- builder_directives.each do |directive_node|
16
- relation = apply_directive(relation, directive_node, args, ctx, resolver_instance)
17
- end
18
-
19
- relation
20
- end
21
-
22
- def self.apply_directive(relation, directive_node, args, ctx, resolver_instance)
23
- class_name = extract_directive_arg(directive_node, 'class')
24
- method_name = extract_directive_arg(directive_node, 'method') || 'call'
25
-
26
- return relation unless class_name
27
-
28
- builder_class = resolver_instance.send(:constantize_model, class_name)
29
- return relation unless builder_class
30
-
31
- if builder_class.respond_to?(method_name)
32
- result = builder_class.public_send(method_name, relation, args, ctx)
33
- result || relation
34
- elsif builder_class.is_a?(Class)
35
- instance = builder_class.new
36
- if instance.respond_to?(method_name)
37
- result = instance.public_send(method_name, relation, args, ctx)
38
- result || relation
39
- else
40
- relation
41
- end
42
- else
43
- relation
44
- end
45
- rescue ::GraphQL::ExecutionError
46
- # Client-facing errors (e.g. authorization / scoping guards raised by a
47
- # builder) MUST propagate — never silently fall back to the unscoped
48
- # relation, or a builder that enforces tenancy could be bypassed.
49
- raise
50
- rescue StandardError => e
51
- Lighthouse::Config.handle_error(e)
52
- relation
53
- end
54
-
55
- def self.extract_directive_arg(directive_node, name)
56
- arg = directive_node.arguments&.find { |a| a.name == name }
57
- return nil unless arg
58
- arg.value.respond_to?(:value) ? arg.value.value : arg.value
59
- end
60
- end
5
+ class Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier
6
+ DEFINITION = 'directive @builder(class: String!, method: String) repeatable on FIELD_DEFINITION'
7
+
8
+ def self.apply(relation, field_def_ast_node, args, ctx, resolver_instance)
9
+ return relation unless field_def_ast_node&.directives
10
+
11
+ builder_directives = field_def_ast_node.directives.select { |d| d.name == 'builder' }
12
+ return relation if builder_directives.empty?
13
+
14
+ builder_directives.each do |directive_node|
15
+ relation = apply_directive(relation, directive_node, args, ctx, resolver_instance)
61
16
  end
17
+
18
+ relation
19
+ end
20
+
21
+ def self.apply_directive(relation, directive_node, args, ctx, _resolver_instance)
22
+ class_name = extract_directive_arg(directive_node, 'class')
23
+ method_name = extract_directive_arg(directive_node, 'method') || 'call'
24
+
25
+ return relation unless class_name
26
+
27
+ # Builders are not resolvers: they compose onto a relation, and live in
28
+ # their own namespace (Config.namespaces[:builders], app/graphql/builders).
29
+ builder_class = Lighthouse::Support::ModelResolver.constantize(
30
+ class_name, namespaces: Lighthouse::Config.namespaces[:builders]
31
+ )
32
+ return relation unless builder_class
33
+
34
+ receiver = builder_receiver(builder_class, method_name)
35
+ return relation unless receiver
36
+
37
+ receiver.public_send(method_name, relation, args, ctx) || relation
38
+ rescue ::GraphQL::ExecutionError
39
+ # Client-facing errors (e.g. authorization / scoping guards raised by a
40
+ # builder) MUST propagate — never silently fall back to the unscoped
41
+ # relation, or a builder that enforces tenancy could be bypassed.
42
+ raise
43
+ rescue StandardError => e
44
+ Lighthouse::Config.handle_error(e)
45
+ relation
46
+ end
47
+
48
+ def self.extract_directive_arg(directive_node, name)
49
+ arg = directive_node.arguments&.find { |a| a.name == name }
50
+ return nil unless arg
51
+
52
+ arg.value.respond_to?(:value) ? arg.value.value : arg.value
53
+ end
54
+
55
+ # What answers the builder method: the class itself, else an instance
56
+ # of it; nil when neither does.
57
+ def self.builder_receiver(builder_class, method_name)
58
+ return builder_class if builder_class.respond_to?(method_name)
59
+ return nil unless builder_class.is_a?(Class)
60
+
61
+ instance = builder_class.new
62
+ instance if instance.respond_to?(method_name)
62
63
  end
64
+ private_class_method :builder_receiver
63
65
  end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Lighthouse::GraphQL::DirectiveResolvers
6
+ # @cache — keep a field's resolved value in Config.cache_store (Rails.cache).
7
+ #
8
+ # The key covers the parent type and its key (the field marked @cacheKey,
9
+ # else the first `ID` field; none on Query/Mutation), the field, and its
10
+ # arguments; `private: true` adds the authenticated user, so each user gets
11
+ # their own entry (guests share one). `maxAge` is in seconds; without it
12
+ # entries live until @clearCache or eviction. Lists and relations are
13
+ # cached as arrays; a value the store cannot serialize (e.g. a connection)
14
+ # is reported through Config.on_error and returned uncached.
15
+ class CacheDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
16
+ def self.directive_name = 'cache'
17
+ def self.definition = 'directive @cache(maxAge: Int, private: Boolean = false) on FIELD_DEFINITION'
18
+
19
+ ROOT_TYPES = %w[Query Mutation Subscription].freeze
20
+
21
+ def define_resolver(target_type_class)
22
+ type_name = @type_def.graphql_name
23
+ field_name = @field_def.name
24
+ max_age = directive_arg('maxAge')
25
+ per_user = directive_arg('private') == true
26
+ parent_key = parent_key_reader
27
+ inner = capture_inner(target_type_class)
28
+
29
+ install(target_type_class, lambda do |obj, args, ctx|
30
+ user_key = per_user ? CacheDirectiveResolver.user_key(ctx) : nil
31
+ key = Lighthouse::GraphQL::Caching.entry_key(type_name, parent_key&.call(obj), field_name, args, user_key)
32
+ cached = Lighthouse::GraphQL::Caching.store.read(key)
33
+ next cached[:value] if cached.is_a?(Hash) && cached.key?(:value)
34
+
35
+ value = CacheDirectiveResolver.materialize(inner.call(obj, args, ctx))
36
+ CacheDirectiveResolver.write(key, value, max_age, ctx)
37
+ value
38
+ end)
39
+ end
40
+
41
+ def self.user_key(ctx)
42
+ user = Lighthouse::Support::Authorization.authenticated_user(ctx)
43
+ return nil if user.nil?
44
+
45
+ "user:#{user.respond_to?(:id) ? user.id : user}"
46
+ end
47
+
48
+ def self.materialize(value)
49
+ Lighthouse::Support::Arguments.list(value) || value
50
+ end
51
+
52
+ def self.write(key, value, max_age, ctx)
53
+ Lighthouse::GraphQL::Caching.store.write(key, { value: value }, expires_in: max_age)
54
+ rescue TypeError, ArgumentError, NoMethodError => e
55
+ Lighthouse::Config.handle_error(e, ctx)
56
+ end
57
+
58
+ private
59
+
60
+ # A callable reading the parent's cache key, or nil on root types.
61
+ def parent_key_reader
62
+ return nil if ROOT_TYPES.include?(@type_def.graphql_name)
63
+
64
+ key_field = cache_key_field
65
+ return nil unless key_field
66
+
67
+ name = key_field.name
68
+ ->(obj) { obj.nil? ? nil : Lighthouse::Support::Arguments.read(obj, name) }
69
+ end
70
+
71
+ # The field marked @cacheKey, else the first `ID` field.
72
+ def cache_key_field
73
+ fields = @type_def.fields.values
74
+ fields.find { |f| cache_key?(f) } || fields.find { |f| f.type.unwrap.graphql_name == 'ID' }
75
+ end
76
+
77
+ def cache_key?(field)
78
+ field.ast_node&.directives&.any? { |d| d.name == 'cacheKey' }
79
+ end
80
+ end
81
+
82
+ # @clearCache — on a mutation, invalidate @cache entries once the field
83
+ # resolved successfully. `type` alone clears all of that type's entries;
84
+ # `idSource` narrows it to the ids found at an argument path
85
+ # (`{ argument: "input.id" }`) or a result path (`{ field: "*.id" }`), and
86
+ # `field` to a single field.
87
+ class ClearCacheDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
88
+ def self.directive_name = 'clearCache'
89
+
90
+ def self.definition
91
+ 'directive @clearCache(type: String!, idSource: ClearCacheIdSource, field: String) ' \
92
+ 'repeatable on FIELD_DEFINITION'
93
+ end
94
+
95
+ def self.type_definitions
96
+ {
97
+ 'ClearCacheIdSource' => <<~GRAPHQL.chomp
98
+ "Where @clearCache finds the ids to clear. Exactly one of the fields must be given."
99
+ input ClearCacheIdSource {
100
+ "Path of an argument the field is applied to, e.g. \\"input.id\\"."
101
+ argument: String
102
+ "Path of a field in the result, e.g. \\"*.id\\" for a list."
103
+ field: String
104
+ }
105
+ GRAPHQL
106
+ }
107
+ end
108
+
109
+ def define_resolver(target_type_class)
110
+ type = directive_arg('type')
111
+ field = directive_arg('field')
112
+ source = directive_arg('idSource') || {}
113
+ inner = capture_inner(target_type_class)
114
+
115
+ install(target_type_class, lambda do |obj, args, ctx|
116
+ result = inner.call(obj, args, ctx)
117
+ ids =
118
+ if source['argument']
119
+ Lighthouse::Support::Arguments.dig(Lighthouse::Support::Arguments.plain(args), source['argument'])
120
+ elsif source['field']
121
+ Lighthouse::Support::Arguments.dig(result, source['field'])
122
+ end
123
+
124
+ if ids.nil?
125
+ Lighthouse::GraphQL::Caching.clear(type, field: field)
126
+ else
127
+ ids.flatten.uniq.each { |id| Lighthouse::GraphQL::Caching.clear(type, id: id, field: field) }
128
+ end
129
+ result
130
+ end)
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Lighthouse::GraphQL::DirectiveResolvers
6
+ # The @can* family — PHP Lighthouse's authorization directives, checked
7
+ # with Pundit by default (Config.authorizer): `ability: "update"` calls
8
+ # `PostPolicy#update?`. Each directive differs only in WHAT it authorizes:
9
+ #
10
+ # @canFind the models whose primary keys an argument holds
11
+ # @canModel the model class (e.g. create? before a @create)
12
+ # @canQuery the models the field's query would return
13
+ # @canResolved whatever the field resolved to (each item of a list)
14
+ # @canRoot the parent object
15
+ #
16
+ # Shared arguments: `injectArgs` passes the field's arguments to the policy
17
+ # method, `args` passes static values after them, and `action` decides what
18
+ # a denial (or a @canFind miss) does — pass the error, replace it with a
19
+ # generic "This action is unauthorized.", or return `returnValue` instead.
20
+ # All are repeatable; they run after @guard and before validation.
21
+ class CanDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
22
+ COMMON_ARGUMENTS = 'ability: String!, injectArgs: Boolean = false, args: CanArgs, ' \
23
+ 'action: CanAction = EXCEPTION_PASS, returnValue: CanArgs'
24
+
25
+ TYPE_DEFINITIONS = {
26
+ 'CanArgs' => '"Any constant literal value: https://graphql.github.io/graphql-spec/draft/#sec-Input-Values"' \
27
+ "\nscalar CanArgs",
28
+ 'CanAction' => <<~GRAPHQL.chomp
29
+ "What a @can* directive does when the policy denies the ability."
30
+ enum CanAction {
31
+ "Pass the error to the client."
32
+ EXCEPTION_PASS
33
+ "Throw a generic \\"This action is unauthorized.\\" error, concealing the real one."
34
+ EXCEPTION_NOT_AUTHORIZED
35
+ "Return the value of returnValue instead of resolving the field."
36
+ RETURN_VALUE
37
+ }
38
+ GRAPHQL
39
+ }.freeze
40
+
41
+ def self.type_definitions = TYPE_DEFINITIONS
42
+
43
+ def self.definition_with(extra = nil)
44
+ "directive @#{directive_name}(#{[COMMON_ARGUMENTS, extra].compact.join(', ')}) repeatable on FIELD_DEFINITION"
45
+ end
46
+
47
+ def define_resolver(target_type_class)
48
+ me = self
49
+ inner = capture_inner(target_type_class)
50
+ install(target_type_class, lambda do |obj, args, ctx|
51
+ catch(me) { me.send(:authorize_and_resolve, inner, obj, args, ctx) }
52
+ end)
53
+ end
54
+
55
+ protected
56
+
57
+ def authorize_and_resolve(inner, obj, args, ctx)
58
+ check { authorize_before(obj, args, ctx) }
59
+ inner.call(obj, args, ctx)
60
+ end
61
+
62
+ def authorize_before(_obj, _args, _ctx)
63
+ raise NotImplementedError
64
+ end
65
+
66
+ # Run an authorization step, turning its failure into what `action:` asks for.
67
+ def check
68
+ yield
69
+ rescue ::GraphQL::ExecutionError
70
+ case directive_arg('action')
71
+ when 'EXCEPTION_NOT_AUTHORIZED' then raise Lighthouse::AuthorizationError
72
+ when 'RETURN_VALUE' then throw(self, directive_arg('returnValue'))
73
+ else raise
74
+ end
75
+ end
76
+
77
+ def authorize!(ctx, record, args)
78
+ ability = directive_arg('ability')
79
+ return if Lighthouse::Support::Authorization.authorized?(ctx, record, ability, policy_args(args))
80
+
81
+ raise Lighthouse::AuthorizationError
82
+ end
83
+
84
+ def authorize_each!(ctx, records, args)
85
+ records.each { |record| authorize!(ctx, record, args) }
86
+ end
87
+
88
+ def policy_args(args)
89
+ values = []
90
+ values << Lighthouse::Support::Arguments.plain(args) if directive_arg('injectArgs')
91
+ static = directive_arg('args')
92
+ values.concat(static.is_a?(Array) ? static : [static]) unless static.nil?
93
+ values
94
+ end
95
+
96
+ def scoped(relation)
97
+ Array(directive_arg('scopes')).reduce(relation) { |acc, scope| acc.public_send(scope) }
98
+ end
99
+ end
100
+
101
+ class CanFindDirectiveResolver < CanDirectiveResolver
102
+ def self.directive_name = 'canFind'
103
+ def self.definition = definition_with('find: String!, model: String, findOrFail: Boolean = true, scopes: [String!]')
104
+
105
+ protected
106
+
107
+ def authorize_before(_obj, args, ctx)
108
+ model = model_class
109
+ ids = Lighthouse::Support::Arguments.dig(Lighthouse::Support::Arguments.plain(args), directive_arg('find')).flatten.uniq
110
+ records = scoped(model.where(model.primary_key => ids)).to_a
111
+ if directive_arg('findOrFail') != false && records.size < ids.size
112
+ missing = ids.map(&:to_s) - records.map { |r| r.public_send(model.primary_key).to_s }
113
+ raise ::GraphQL::ExecutionError, "Couldn't find #{model.name} with '#{model.primary_key}'=#{missing.join(', ')}"
114
+ end
115
+
116
+ authorize_each!(ctx, records, args)
117
+ end
118
+ end
119
+
120
+ class CanModelDirectiveResolver < CanDirectiveResolver
121
+ def self.directive_name = 'canModel'
122
+ def self.definition = definition_with('model: String')
123
+
124
+ protected
125
+
126
+ def authorize_before(_obj, args, ctx)
127
+ authorize!(ctx, model_class, args)
128
+ end
129
+ end
130
+
131
+ class CanQueryDirectiveResolver < CanDirectiveResolver
132
+ def self.directive_name = 'canQuery'
133
+ def self.definition = definition_with('model: String, scopes: [String!]')
134
+
135
+ protected
136
+
137
+ # The records the field's argument filters (@eq, @where, ...) select.
138
+ def authorize_before(_obj, args, ctx)
139
+ authorize_each!(ctx, apply_where_filters(scoped(model_class.all), args).to_a, args)
140
+ end
141
+ end
142
+
143
+ class CanResolvedDirectiveResolver < CanDirectiveResolver
144
+ def self.directive_name = 'canResolved'
145
+ def self.definition = definition_with
146
+
147
+ protected
148
+
149
+ # The resolver runs first: only use it on fields that do not mutate data.
150
+ def authorize_and_resolve(inner, obj, args, ctx)
151
+ result = inner.call(obj, args, ctx)
152
+ check { authorize_each!(ctx, resolved_records(result), args) }
153
+ result
154
+ end
155
+
156
+ def resolved_records(result)
157
+ case result
158
+ when nil then []
159
+ when Lighthouse::GraphQL::Pagination::Paginator then result.data
160
+ when Lighthouse::GraphQL::Pagination::Connection then result.edges.pluck('node')
161
+ when Hash then result.key?('data') ? Array(result['data']) : [result] # a simple paginator, or a record as a Hash
162
+ else Lighthouse::Support::Arguments.list(result) || [result]
163
+ end
164
+ end
165
+ end
166
+
167
+ class CanRootDirectiveResolver < CanDirectiveResolver
168
+ def self.directive_name = 'canRoot'
169
+ def self.definition = definition_with
170
+
171
+ protected
172
+
173
+ def authorize_before(obj, args, ctx)
174
+ authorize!(ctx, obj, args)
175
+ end
176
+ end
177
+ end