lighthouse-graphql 0.1.1 → 0.2.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 (112) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +175 -1
  3. data/README.md +47 -3
  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/motivation.md +184 -0
  12. data/docs/mutations.md +260 -0
  13. data/docs/performance.md +138 -0
  14. data/docs/relationships.md +80 -33
  15. data/docs/resolvers.md +189 -0
  16. data/docs/validation.md +132 -0
  17. data/lib/generators/lighthouse/builder_generator.rb +30 -0
  18. data/lib/generators/lighthouse/class_generator.rb +61 -0
  19. data/lib/generators/lighthouse/install/install_generator.rb +127 -0
  20. data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +8 -0
  21. data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +57 -0
  22. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +67 -0
  23. data/lib/generators/lighthouse/install/templates/schema.graphql.tt +19 -0
  24. data/lib/generators/lighthouse/install/templates/schema.rb.tt +10 -0
  25. data/lib/generators/lighthouse/mutation_generator.rb +30 -0
  26. data/lib/generators/lighthouse/query_generator.rb +29 -0
  27. data/lib/generators/lighthouse/validator_generator.rb +36 -0
  28. data/lib/lighthouse/cache_control.rb +126 -0
  29. data/lib/lighthouse/caching.rb +68 -0
  30. data/lib/lighthouse/config.rb +85 -19
  31. data/lib/lighthouse/contracts/arg_builder.rb +8 -12
  32. data/lib/lighthouse/contracts/arg_manipulator.rb +8 -12
  33. data/lib/lighthouse/contracts/directive.rb +10 -14
  34. data/lib/lighthouse/contracts/field_manipulator.rb +8 -12
  35. data/lib/lighthouse/contracts/field_middleware.rb +7 -11
  36. data/lib/lighthouse/contracts/field_resolver.rb +9 -13
  37. data/lib/lighthouse/dataloader_sources/association_loader.rb +28 -19
  38. data/lib/lighthouse/dataloader_sources/model_loader.rb +10 -14
  39. data/lib/lighthouse/dataloader_sources/relation_count_loader.rb +16 -0
  40. data/lib/lighthouse/dataloader_sources/relation_page_loader.rb +18 -0
  41. data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +57 -69
  42. data/lib/lighthouse/directive_resolvers/all_resolver.rb +26 -57
  43. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +161 -0
  44. data/lib/lighthouse/directive_resolvers/auth_resolver.rb +41 -38
  45. data/lib/lighthouse/directive_resolvers/base.rb +142 -86
  46. data/lib/lighthouse/directive_resolvers/builder_applier.rb +58 -56
  47. data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +133 -0
  48. data/lib/lighthouse/directive_resolvers/can_resolvers.rb +177 -0
  49. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +70 -0
  50. data/lib/lighthouse/directive_resolvers/count_resolver.rb +42 -48
  51. data/lib/lighthouse/directive_resolvers/field_resolver.rb +27 -68
  52. data/lib/lighthouse/directive_resolvers/find_resolver.rb +42 -52
  53. data/lib/lighthouse/directive_resolvers/first_resolver.rb +39 -45
  54. data/lib/lighthouse/directive_resolvers/guard_resolver.rb +16 -22
  55. data/lib/lighthouse/directive_resolvers/method_resolver.rb +19 -25
  56. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +219 -0
  57. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +70 -128
  58. data/lib/lighthouse/directive_resolvers/rename_resolver.rb +18 -24
  59. data/lib/lighthouse/directive_resolvers/resolution.rb +31 -27
  60. data/lib/lighthouse/directive_resolvers/validator_resolver.rb +193 -0
  61. data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +164 -163
  62. data/lib/lighthouse/directives/arguments/base.rb +23 -31
  63. data/lib/lighthouse/directives/arguments/filters.rb +113 -93
  64. data/lib/lighthouse/directives/arguments/order_by.rb +41 -41
  65. data/lib/lighthouse/directives/pagination_info_types.rb +69 -0
  66. data/lib/lighthouse/directives/pagination_manipulator.rb +176 -0
  67. data/lib/lighthouse/directives/registry.rb +210 -141
  68. data/lib/lighthouse/directives/where_conditions_directive.rb +236 -200
  69. data/lib/lighthouse/errors.rb +27 -0
  70. data/lib/lighthouse/federation_directives.rb +53 -0
  71. data/lib/lighthouse/field_resolver_stack.rb +56 -0
  72. data/lib/lighthouse/generated_resolvers.rb +82 -0
  73. data/lib/lighthouse/graphql/version.rb +1 -1
  74. data/lib/lighthouse/graphql_ruby_patches.rb +18 -0
  75. data/lib/lighthouse/ide_helper.rb +98 -0
  76. data/lib/lighthouse/namespaces.rb +21 -0
  77. data/lib/lighthouse/nested_mutations/belongs_to_operations.rb +50 -0
  78. data/lib/lighthouse/nested_mutations/executor.rb +149 -0
  79. data/lib/lighthouse/nested_mutations/has_operations.rb +66 -0
  80. data/lib/lighthouse/nested_mutations/many_to_many_operations.rb +44 -0
  81. data/lib/lighthouse/nested_mutations/partitioner.rb +125 -0
  82. data/lib/lighthouse/nested_mutations.rb +210 -0
  83. data/lib/lighthouse/pagination.rb +221 -0
  84. data/lib/lighthouse/persisted_queries.rb +167 -0
  85. data/lib/lighthouse/railtie.rb +26 -0
  86. data/lib/lighthouse/rb_lighthouse.rb +50 -29
  87. data/lib/lighthouse/reference_resolver.rb +13 -15
  88. data/lib/lighthouse/relation_batch.rb +156 -0
  89. data/lib/lighthouse/resolver_dispatch.rb +26 -0
  90. data/lib/lighthouse/schema_cache.rb +120 -0
  91. data/lib/lighthouse/schema_factory.rb +27 -37
  92. data/lib/lighthouse/schema_generator.rb +196 -239
  93. data/lib/lighthouse/schema_implementation.rb +51 -48
  94. data/lib/lighthouse/sdl_loader.rb +17 -20
  95. data/lib/lighthouse/support/arguments.rb +119 -0
  96. data/lib/lighthouse/support/authorization.rb +18 -19
  97. data/lib/lighthouse/support/batching.rb +16 -0
  98. data/lib/lighthouse/support/directive_args.rb +26 -24
  99. data/lib/lighthouse/support/model_resolver.rb +56 -56
  100. data/lib/lighthouse/support/naming.rb +55 -59
  101. data/lib/lighthouse/tasks/lighthouse.rake +33 -0
  102. data/lib/lighthouse/where_conditions/operator_map.rb +25 -29
  103. data/sig/lighthouse/directives.rbs +158 -0
  104. data/sig/lighthouse/graphql.rbs +78 -0
  105. data/sig/lighthouse.rbs +142 -0
  106. metadata +56 -64
  107. data/lib/lighthouse/directive_resolvers/belongs_to_many_resolver.rb +0 -56
  108. data/lib/lighthouse/directive_resolvers/belongs_to_resolver.rb +0 -47
  109. data/lib/lighthouse/directive_resolvers/can_resolver.rb +0 -86
  110. data/lib/lighthouse/directive_resolvers/has_many_resolver.rb +0 -109
  111. data/lib/lighthouse/directive_resolvers/has_one_resolver.rb +0 -43
  112. data/lib/lighthouse/directives/relation_directive.rb +0 -332
@@ -1,147 +1,89 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ostruct'
4
3
  require_relative 'base'
5
4
  require_relative 'builder_applier'
6
5
 
7
- module Lighthouse
8
- module GraphQL
9
- module DirectiveResolvers
10
- class PaginateDirectiveResolver < BaseDirectiveResolver
11
- class PaginatorResult
12
- attr_reader :paginator_info, :data
13
-
14
- def initialize(paginator_info:, data:)
15
- @paginator_info = paginator_info
16
- @data = data
17
- end
18
-
19
- def paginatorInfo
20
- @paginator_info
21
- end
22
- end
23
-
24
- def define_resolver(target_type_class)
25
- field_name = @field_def.name
26
- method_name = "resolve_field_#{field_name}"
27
-
28
- # Resolve model name now, but constantize lazily at call time so
29
- # Rails autoloading (Zeitwerk) can load the model after startup.
30
- model_name_static = extract_model_name || extract_model_name_from_field_type
31
- # If the field was transformed to a Paginator type (e.g. InboxPaginator),
32
- # derive the underlying model name by stripping a trailing "Paginator".
33
- model_name_static = model_name_static.to_s.sub(/Paginator\z/, '')
34
- # Closure to lookup the model class lazily
35
- model_lookup = proc { constantize_model(model_name_static) }
36
-
37
-
38
-
39
- # Determine whether this paginate directive requested a cursor/connection
40
- type_arg = extract_type_name
41
- is_connection_type = type_arg && %w[CONNECTION CURSOR].include?(type_arg.to_s.upcase)
42
-
43
- # Paginator object builder (responds to both `paginatorInfo` and `paginator_info`)
44
- paginator_builder = proc do |pinfo, data|
45
- paginator_info_object = OpenStruct.new(pinfo)
46
- # Ensure both camelCase and underscored-methods can be resolved by fallback
47
- if pinfo.is_a?(Hash)
48
- underscored = {}
49
- pinfo.each_key do |k|
50
- key = k.to_s
51
- snake = key.underscore
52
- underscored[snake] = pinfo[k] unless pinfo.key?(snake)
53
- end
54
- underscored.each { |kk, vv| paginator_info_object[kk] = vv }
55
- end
56
-
57
- PaginatorResult.new(paginator_info: paginator_info_object, data: data)
58
- end
59
-
60
- # Define owner singleton method to support graphql-ruby's execution
61
- resolver_instance = self
62
- field_ast_node = @field_def.ast_node
63
- target_type_class.define_singleton_method(method_name) do |obj, arguments, ctx|
64
- model_class = model_lookup.call
65
- return [] unless model_class
66
-
67
- args_h = arguments.respond_to?(:to_h) ? arguments.to_h : {}
68
-
69
- limit = args_h[:first]
70
- offset = 0
71
- if args_h[:after]
72
- begin
73
- decoded = Base64.decode64(args_h[:after])
74
- if decoded.start_with?('offset:')
75
- offset = decoded.split(':', 2)[1].to_i
76
- end
77
- rescue StandardError
78
- offset = 0
79
- end
80
- end
6
+ # @paginate — paginate a model query, as PHP Lighthouse does. On a nested
7
+ # field it pages the parent's association of the same name.
8
+ #
9
+ # users(name: String @like): [User!]! @paginate(defaultCount: 25)
10
+ #
11
+ # `type:` picks PAGINATOR (default), SIMPLE or CONNECTION (see
12
+ # Directives::PaginationManipulator for the generated schema). The query is
13
+ # the model's (`model:`, else the return type), narrowed by `scopes:`,
14
+ # @builder and the field's argument filters (@eq, @where, @orderBy,
15
+ # @whereConditions, ...). `maxCount` caps `first` (null: unlimited),
16
+ # overriding Config.pagination_max_count.
17
+ class Lighthouse::GraphQL::DirectiveResolvers::PaginateDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
18
+ def self.directive_name = 'paginate'
19
+
20
+ def self.definition
21
+ 'directive @paginate(type: PaginateType = PAGINATOR, model: String, scopes: [String!], ' \
22
+ 'defaultCount: Int, maxCount: Int) on FIELD_DEFINITION'
23
+ end
81
24
 
82
- relation = model_class.all
25
+ def self.type_definitions
26
+ {
27
+ 'PaginateType' => <<~GRAPHQL.chomp
28
+ "The type of pagination a field uses."
29
+ enum PaginateType {
30
+ "Offset-based pagination with page numbers and a total, like Laravel's LengthAwarePaginator."
31
+ PAGINATOR
32
+ "Offset-based pagination without a total count, which saves a query."
33
+ SIMPLE
34
+ "Cursor-based pagination, compatible with the Relay specification."
35
+ CONNECTION
36
+ }
37
+ GRAPHQL
38
+ }
39
+ end
83
40
 
84
- relation = DirectiveResolvers::BuilderApplier.apply(relation, field_ast_node, args_h, ctx, resolver_instance)
41
+ def define_resolver(target_type_class)
42
+ me = self
43
+ install(target_type_class, lambda do |obj, args, ctx|
44
+ me.send(:resolve_page, obj, Lighthouse::Support::Arguments.shallow(args), ctx)
45
+ end)
46
+ end
85
47
 
86
- relation = DirectiveResolvers::WhereConditionsApplier.apply(relation, args_h[:where] || args_h['where'])
87
- relation = relation.offset(offset) if offset > 0
88
- relation = relation.limit(limit) if limit
48
+ protected
89
49
 
90
- if is_connection_type
91
- ::GraphQL::Pagination::ArrayConnection.new(relation, context: ctx, parent: nil, field: nil, arguments: {})
92
- else
93
- data = relation.to_a
94
- per_page = limit
95
- total = relation.count
96
- paginator_info = {
97
- count: data.size,
98
- currentPage: 1,
99
- firstItem: data.any? ? (offset + 1) : nil,
100
- hasMorePages: (offset + data.size) < total,
101
- lastItem: data.any? ? (offset + data.size) : nil,
102
- lastPage: per_page && per_page > 0 ? (total.to_f / per_page).ceil : 1,
103
- perPage: per_page || data.size,
104
- total: total
105
- }
50
+ def resolve_page(obj, args, ctx)
51
+ Lighthouse::GraphQL::Pagination.paginate(page_list(obj, args, ctx), (directive_arg('type') || 'PAGINATOR').to_s.upcase, args, limits)
52
+ end
106
53
 
107
- paginator_builder.call(paginator_info, data)
108
- end
109
- end
54
+ # On a nested field, the parent's association of that name (as in PHP
55
+ # Lighthouse, `User.posts @paginate` pages the user's posts), batched with
56
+ # the other parents' when the Dataloader can; otherwise, or with `model:`,
57
+ # the model's whole table.
58
+ def page_list(obj, args, ctx)
59
+ name = Lighthouse::Support::Naming.attribute(@field_def.name)
60
+ return decorate_relation(model_class.all, args, ctx) unless nested?(obj, name)
110
61
 
111
- # Attach a field-level resolve_proc that delegates to the generated
112
- # singleton resolver. This keeps runtime dispatch consistent with
113
- # SchemaImplementation (which checks for resolve_field_* first).
114
- if @field_def.respond_to?(:resolve_proc=)
115
- @field_def.resolve_proc = ->(obj, arguments, ctx) do
116
- target_type_class.public_send(method_name, obj, arguments, ctx)
117
- end
118
- end
119
- end
62
+ dataloader = Lighthouse::Support::Batching.dataloader(ctx)
63
+ batch = dataloader && Lighthouse::GraphQL::RelationBatch.for(obj, name, args, self, ctx)
64
+ return Lighthouse::GraphQL::Pagination::BatchedList.new(batch, obj, dataloader) if batch
120
65
 
121
- private
66
+ decorate_relation(obj.association(name.to_sym).scope, args, ctx)
67
+ end
122
68
 
123
- def extract_model_name
124
- arg = @directive_node.arguments&.find { |a| a.name == 'model' }
125
- return nil unless arg
126
- arg.value.respond_to?(:value) ? arg.value.value : arg.value
127
- end
69
+ def nested?(obj, name)
70
+ !directive_arg('model') && obj.respond_to?(:association) && obj.class.reflect_on_association(name.to_sym)
71
+ end
128
72
 
73
+ public
129
74
 
130
- def extract_type_name
131
- arg = @directive_node.arguments&.find { |a| a.name == 'type' }
132
- return nil unless arg
133
- arg.value.respond_to?(:value) ? arg.value.value : arg.value
134
- end
75
+ # `scopes:`, @builder and the argument filters, on one relation or a RelationBatch.
76
+ def decorate_relation(relation, args, ctx)
77
+ relation = Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.apply(apply_scopes(relation), @field_def.ast_node, args, ctx, self)
78
+ apply_where_filters(relation, args)
79
+ end
135
80
 
136
- def extract_model_name_from_field_type
137
- unwrapped = @field_def.type.respond_to?(:unwrap) ? @field_def.type.unwrap : @field_def.type
138
- return unwrapped.graphql_name if unwrapped.respond_to?(:graphql_name) && unwrapped.graphql_name
139
- return unwrapped.name if unwrapped.respond_to?(:name) && unwrapped.name
81
+ protected
140
82
 
141
- # fallback for late-bound signatures like "InboxPaginator!"
142
- @field_def.type.to_type_signature.to_s.gsub(/[!\[\]]/, '')
143
- end
144
- end
145
- end
83
+ def limits
84
+ limits = { default_count: directive_arg('defaultCount') }
85
+ given = @directive_node.arguments.any? { |a| a.name == 'maxCount' }
86
+ limits[:max_count] = directive_arg('maxCount') || :unlimited if given
87
+ limits
146
88
  end
147
89
  end
@@ -2,34 +2,28 @@
2
2
 
3
3
  require_relative 'base'
4
4
 
5
- module Lighthouse
6
- module GraphQL
7
- module DirectiveResolvers
8
- # @rename(attribute: "snake_case_name") — explicit override of the default
9
- # field reader. Reads the named attribute off the parent object regardless
10
- # of the GraphQL field name.
11
- class RenameDirectiveResolver < BaseDirectiveResolver
12
- def self.directive_name = 'rename'
13
- def self.definition = 'directive @rename(attribute: String!) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION'
5
+ # @rename(attribute: "snake_case_name") — explicit override of the default
6
+ # field reader. Reads the named attribute off the parent object regardless
7
+ # of the GraphQL field name.
8
+ class Lighthouse::GraphQL::DirectiveResolvers::RenameDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
9
+ def self.directive_name = 'rename'
10
+ def self.definition = 'directive @rename(attribute: String!) on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION'
14
11
 
15
- def define_resolver(target_type_class)
16
- method_name = "resolve_field_#{@field_def.name}"
17
- attribute = Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'attribute').to_s
12
+ def define_resolver(target_type_class)
13
+ method_name = "resolve_field_#{@field_def.name}"
14
+ attribute = Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'attribute').to_s
18
15
 
19
- resolver = lambda do |obj, _arguments, _ctx|
20
- next nil if obj.nil?
16
+ resolver = lambda do |obj, _arguments, _ctx|
17
+ next nil if obj.nil?
21
18
 
22
- if obj.is_a?(Hash)
23
- obj[attribute] || obj[attribute.to_sym]
24
- elsif obj.respond_to?(attribute)
25
- obj.public_send(attribute)
26
- end
27
- end
28
-
29
- target_type_class.define_singleton_method(method_name, &resolver)
30
- @field_def.resolve_proc = resolver if @field_def.respond_to?(:resolve_proc=)
31
- end
19
+ if obj.is_a?(Hash)
20
+ obj[attribute] || obj[attribute.to_sym]
21
+ elsif obj.respond_to?(attribute)
22
+ obj.public_send(attribute)
32
23
  end
33
24
  end
25
+
26
+ target_type_class.define_singleton_method(method_name, &resolver)
27
+ @field_def.resolve_proc = resolver if @field_def.respond_to?(:resolve_proc=)
34
28
  end
35
29
  end
@@ -1,35 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Lighthouse
4
- module GraphQL
5
- module DirectiveResolvers
6
- # Helper for "middleware" directives (@guard, @can) that need to wrap a
7
- # field's existing resolver.
8
- module Resolution
9
- module_function
3
+ # Helper for "middleware" directives (@guard, @can) that need to wrap a
4
+ # field's existing resolver.
5
+ module Lighthouse::GraphQL::DirectiveResolvers::Resolution
6
+ module_function
10
7
 
11
- # Capture the field's current resolver (generated singleton method,
12
- # resolve_proc, or the default attribute reader) as a callable
13
- # (obj, args, ctx) -> value, so it can be wrapped. The returned callable
14
- # is unaffected by subsequently redefining the singleton method.
15
- def capture(target_type_class, method_name, field_def, field_name)
16
- original_singleton = target_type_class.method(method_name) if target_type_class.respond_to?(method_name)
17
- original_proc = field_def.resolve_proc if field_def.respond_to?(:resolve_proc) && field_def.resolve_proc
8
+ # Capture the field's current resolver (generated singleton method,
9
+ # resolve_proc, or the default attribute reader) as a callable
10
+ # (obj, args, ctx) -> value, so it can be wrapped. The returned callable
11
+ # is unaffected by subsequently redefining the singleton method.
12
+ def capture(target_type_class, method_name, field_def, field_name)
13
+ original_singleton = lighthouse_singleton(target_type_class, method_name)
14
+ original_proc = field_def.resolve_proc if field_def.respond_to?(:resolve_proc) && field_def.resolve_proc
18
15
 
19
- lambda do |obj, arguments, ctx|
20
- if original_singleton
21
- original_singleton.call(obj, arguments, ctx)
22
- elsif original_proc
23
- original_proc.call(obj, arguments, ctx)
24
- elsif obj.respond_to?(field_name)
25
- obj.public_send(field_name)
26
- else
27
- snake = Lighthouse::Support::Naming.attribute(field_name)
28
- obj.respond_to?(snake) ? obj.public_send(snake) : nil
29
- end
30
- end
31
- end
16
+ lambda do |obj, arguments, ctx|
17
+ if original_singleton
18
+ original_singleton.call(obj, arguments, ctx)
19
+ elsif original_proc
20
+ original_proc.call(obj, arguments, ctx)
21
+ elsif obj.respond_to?(field_name)
22
+ obj.public_send(field_name)
23
+ else
24
+ snake = Lighthouse::Support::Naming.attribute(field_name)
25
+ obj.respond_to?(snake) ? obj.public_send(snake) : nil
32
26
  end
33
27
  end
34
28
  end
29
+
30
+ # graphql-ruby's SDL builder defines `resolve_field_*` methods of its own
31
+ # (with a different arity); only ours, defined under lib/lighthouse/, count.
32
+ def lighthouse_singleton(target_type_class, method_name)
33
+ return nil unless target_type_class.respond_to?(method_name)
34
+
35
+ method = target_type_class.method(method_name)
36
+ source_file, = method.source_location
37
+ method if source_file&.include?('/lib/lighthouse/')
38
+ end
35
39
  end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @validator — validate arguments with a contract class before the field
6
+ # resolves, as PHP Lighthouse's validator classes do.
7
+ #
8
+ # type Mutation { updateUser(id: ID!, name: String): User @update @validator }
9
+ # input CreatePostInput @validator { title: String }
10
+ #
11
+ # On a field, the contract gets all the field's arguments; the class
12
+ # defaults to Validators::<ParentType>::<FieldName>Validator. On an input
13
+ # type it gets that input's values wherever the type is used — nested,
14
+ # and per item of a list — defaulting to Validators::<InputType>Validator.
15
+ # Classes are looked up in Config.namespaces[:validators].
16
+ #
17
+ # A contract is a Dry::Validation::Contract (values arrive with
18
+ # snake_case symbol keys; declare `option :graphql_context` to receive the
19
+ # GraphQL context), or any object whose `call(values)` returns such a
20
+ # result or a { key => [messages] } Hash. Failures raise one
21
+ # Lighthouse::ValidationError keyed by GraphQL argument path.
22
+ #
23
+ # Model validations need no directive: @create/@update report them the
24
+ # same way. Use @validator for rules that are not the model's — checks
25
+ # across arguments, arguments of a query, or input that never reaches a model.
26
+ class Lighthouse::GraphQL::DirectiveResolvers::ValidatorDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
27
+ def self.directive_name = 'validator'
28
+ def self.definition = 'directive @validator(class: String) on FIELD_DEFINITION | INPUT_OBJECT'
29
+
30
+ # A default name, e.g. Mutation::SignUpValidator: always looked up under
31
+ # the validators namespaces, although it contains `::`.
32
+ Conventional = Struct.new(:name) do
33
+ def to_s = name
34
+ end
35
+
36
+ # One validation of a field: the GraphQL context the contracts receive,
37
+ # and the messages collected so far, keyed by argument path.
38
+ Pass = Struct.new(:ctx, :messages)
39
+
40
+ # Does any input type the field takes (at any depth) carry @validator?
41
+ def self.input_validators?(field_def, seen = Set.new)
42
+ field_def.arguments.each_value.any? do |argument|
43
+ type = argument.type.unwrap
44
+ next false unless type.kind.input_object? && seen.add?(type.graphql_name)
45
+
46
+ validator_directive(type) || input_validators?(type, seen)
47
+ end
48
+ end
49
+
50
+ def self.validator_directive(type)
51
+ type.ast_node&.directives&.find { |d| d.name == 'validator' }
52
+ end
53
+
54
+ def define_resolver(target_type_class)
55
+ me = self
56
+ field_contract =
57
+ (directive_arg('class') || Conventional.new("#{@type_def.graphql_name}::#{@field_def.name.to_s.camelize}Validator") if @directive_node)
58
+ inner = capture_inner(target_type_class)
59
+
60
+ install(target_type_class, lambda do |obj, args, ctx|
61
+ me.send(:validate!, field_contract, args, ctx)
62
+ inner.call(obj, args, ctx)
63
+ end)
64
+ end
65
+
66
+ protected
67
+
68
+ def validate!(field_contract, args, ctx)
69
+ values = Lighthouse::Support::Arguments.plain(args)
70
+ pass = Pass.new(ctx, {})
71
+ run(field_contract, values, [], @field_def.arguments, pass) if field_contract
72
+ validate_inputs(@field_def.arguments, values, [], pass)
73
+ raise Lighthouse::ValidationError.new(@field_def.name, pass.messages) unless pass.messages.empty?
74
+ end
75
+
76
+ def validate_inputs(argument_defs, values, prefix, pass)
77
+ by_keyword = Lighthouse::Support::Arguments.keyword_index(argument_defs)
78
+ values.each do |key, value|
79
+ argument = by_keyword[key.to_sym]
80
+ next if argument.nil? || value.nil?
81
+
82
+ type = argument.type.unwrap
83
+ next unless type.kind.input_object?
84
+
85
+ input_items(value, prefix + [argument.graphql_name]).each do |item, item_path|
86
+ validate_input(type, item, item_path, pass)
87
+ end
88
+ end
89
+ end
90
+
91
+ # One input object value: its own @validator contract, then its nested inputs.
92
+ def validate_input(type, values, path, pass)
93
+ directive = self.class.validator_directive(type)
94
+ if directive
95
+ name = Lighthouse::Support::DirectiveArgs.literal(directive, 'class') || Conventional.new("#{type.graphql_name}Validator")
96
+ run(name, values, path, type.arguments, pass)
97
+ end
98
+ validate_inputs(type.arguments, values, path, pass)
99
+ end
100
+
101
+ def run(class_name, values, prefix, argument_defs, pass)
102
+ contract = instantiate(validator_class(class_name), pass.ctx)
103
+ errors = error_hash(contract.call(values))
104
+
105
+ flatten(errors).each do |segments, list|
106
+ path = (prefix + graphql_path(segments, argument_defs)).join('.')
107
+ (pass.messages[path.empty? ? @field_def.name : path] ||= []).concat(list)
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ # A list value validates per item, its path extended by the index.
114
+ def input_items(value, path)
115
+ items = Lighthouse::Support::Arguments.list(value)
116
+ return [[value, path]] unless items
117
+
118
+ items.each_with_index.map { |item, i| [item, path + [i.to_s]] }
119
+ end
120
+
121
+ # A contract answers a { key => [messages] } Hash, or a Dry::Validation
122
+ # result whose message set converts to one.
123
+ def error_hash(result)
124
+ return result if result.is_a?(Hash)
125
+ return {} if result.respond_to?(:success?) && result.success?
126
+
127
+ message_set = result.errors
128
+ message_set.to_h
129
+ end
130
+
131
+ def validator_class(name)
132
+ namespaces = Lighthouse::Config.namespaces[:validators]
133
+ found =
134
+ if name.is_a?(Conventional)
135
+ namespaces.lazy.filter_map { |ns| Lighthouse::Support::ModelResolver.constantize("#{ns}::#{name}") }.first
136
+ else
137
+ Lighthouse::Support::ModelResolver.constantize(name, namespaces: namespaces)
138
+ end
139
+ found ||
140
+ raise(Lighthouse::DefinitionError,
141
+ "@validator on #{@type_def.graphql_name}.#{@field_def.name}: no validator class #{name} " \
142
+ "in #{Lighthouse::Config.namespaces[:validators].join(', ')}")
143
+ end
144
+
145
+ def instantiate(klass, ctx)
146
+ return klass unless klass.is_a?(Class)
147
+ return klass.new(graphql_context: ctx) if dry_option?(klass, :graphql_context)
148
+
149
+ klass.new
150
+ end
151
+
152
+ def dry_option?(klass, name)
153
+ klass.respond_to?(:dry_initializer) && klass.dry_initializer.options.any? { |option| option.target == name }
154
+ end
155
+
156
+ # Dry's nested errors hash as [[key, ...], [messages]] pairs; base errors
157
+ # use a nil key.
158
+ def flatten(errors, prefix = [])
159
+ errors.flat_map do |key, value|
160
+ path = key.nil? ? prefix : prefix + [key]
161
+ case value
162
+ when Hash then flatten(value, path)
163
+ when Array then flatten_messages(value, path)
164
+ else [[path, [value.to_s]]]
165
+ end
166
+ end
167
+ end
168
+
169
+ # A key with both its own messages and nested ones comes as
170
+ # [messages, { nested }].
171
+ def flatten_messages(value, path)
172
+ if value.length == 2 && value.first.is_a?(Array) && value.last.is_a?(Hash)
173
+ [[path, value.first]] + flatten(value.last, path)
174
+ else
175
+ [[path, value.map(&:to_s)]]
176
+ end
177
+ end
178
+
179
+ # Map snake_case contract keys back to the GraphQL argument names.
180
+ def graphql_path(segments, argument_defs)
181
+ defs = argument_defs
182
+ segments.map do |segment|
183
+ next segment.to_s if segment.is_a?(Integer)
184
+
185
+ argument = Lighthouse::Support::Arguments.keyword_index(defs)[segment.to_sym]
186
+ next segment.to_s.camelize(:lower) unless argument
187
+
188
+ type = argument.type.unwrap
189
+ defs = type.kind.input_object? ? type.arguments : {}
190
+ argument.graphql_name
191
+ end
192
+ end
193
+ end