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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +175 -1
- data/README.md +47 -3
- data/docs/authorization.md +116 -57
- data/docs/best-practices.md +16 -3
- data/docs/caching.md +161 -0
- data/docs/configuration.md +83 -4
- data/docs/custom-directives.md +5 -1
- data/docs/directives.md +117 -38
- data/docs/getting-started.md +84 -0
- data/docs/motivation.md +184 -0
- data/docs/mutations.md +260 -0
- data/docs/performance.md +138 -0
- data/docs/relationships.md +80 -33
- data/docs/resolvers.md +189 -0
- data/docs/validation.md +132 -0
- data/lib/generators/lighthouse/builder_generator.rb +30 -0
- data/lib/generators/lighthouse/class_generator.rb +61 -0
- data/lib/generators/lighthouse/install/install_generator.rb +127 -0
- data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +8 -0
- data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +57 -0
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +67 -0
- data/lib/generators/lighthouse/install/templates/schema.graphql.tt +19 -0
- data/lib/generators/lighthouse/install/templates/schema.rb.tt +10 -0
- data/lib/generators/lighthouse/mutation_generator.rb +30 -0
- data/lib/generators/lighthouse/query_generator.rb +29 -0
- data/lib/generators/lighthouse/validator_generator.rb +36 -0
- data/lib/lighthouse/cache_control.rb +126 -0
- data/lib/lighthouse/caching.rb +68 -0
- data/lib/lighthouse/config.rb +85 -19
- data/lib/lighthouse/contracts/arg_builder.rb +8 -12
- data/lib/lighthouse/contracts/arg_manipulator.rb +8 -12
- data/lib/lighthouse/contracts/directive.rb +10 -14
- data/lib/lighthouse/contracts/field_manipulator.rb +8 -12
- data/lib/lighthouse/contracts/field_middleware.rb +7 -11
- data/lib/lighthouse/contracts/field_resolver.rb +9 -13
- data/lib/lighthouse/dataloader_sources/association_loader.rb +28 -19
- data/lib/lighthouse/dataloader_sources/model_loader.rb +10 -14
- data/lib/lighthouse/dataloader_sources/relation_count_loader.rb +16 -0
- data/lib/lighthouse/dataloader_sources/relation_page_loader.rb +18 -0
- data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +57 -69
- data/lib/lighthouse/directive_resolvers/all_resolver.rb +26 -57
- data/lib/lighthouse/directive_resolvers/association_resolvers.rb +161 -0
- data/lib/lighthouse/directive_resolvers/auth_resolver.rb +41 -38
- data/lib/lighthouse/directive_resolvers/base.rb +142 -86
- data/lib/lighthouse/directive_resolvers/builder_applier.rb +58 -56
- data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +133 -0
- data/lib/lighthouse/directive_resolvers/can_resolvers.rb +177 -0
- data/lib/lighthouse/directive_resolvers/convention_resolver.rb +70 -0
- data/lib/lighthouse/directive_resolvers/count_resolver.rb +42 -48
- data/lib/lighthouse/directive_resolvers/field_resolver.rb +27 -68
- data/lib/lighthouse/directive_resolvers/find_resolver.rb +42 -52
- data/lib/lighthouse/directive_resolvers/first_resolver.rb +39 -45
- data/lib/lighthouse/directive_resolvers/guard_resolver.rb +16 -22
- data/lib/lighthouse/directive_resolvers/method_resolver.rb +19 -25
- data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +219 -0
- data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +70 -128
- data/lib/lighthouse/directive_resolvers/rename_resolver.rb +18 -24
- data/lib/lighthouse/directive_resolvers/resolution.rb +31 -27
- data/lib/lighthouse/directive_resolvers/validator_resolver.rb +193 -0
- data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +164 -163
- data/lib/lighthouse/directives/arguments/base.rb +23 -31
- data/lib/lighthouse/directives/arguments/filters.rb +113 -93
- data/lib/lighthouse/directives/arguments/order_by.rb +41 -41
- data/lib/lighthouse/directives/pagination_info_types.rb +69 -0
- data/lib/lighthouse/directives/pagination_manipulator.rb +176 -0
- data/lib/lighthouse/directives/registry.rb +210 -141
- data/lib/lighthouse/directives/where_conditions_directive.rb +236 -200
- data/lib/lighthouse/errors.rb +27 -0
- data/lib/lighthouse/federation_directives.rb +53 -0
- data/lib/lighthouse/field_resolver_stack.rb +56 -0
- data/lib/lighthouse/generated_resolvers.rb +82 -0
- data/lib/lighthouse/graphql/version.rb +1 -1
- data/lib/lighthouse/graphql_ruby_patches.rb +18 -0
- data/lib/lighthouse/ide_helper.rb +98 -0
- data/lib/lighthouse/namespaces.rb +21 -0
- data/lib/lighthouse/nested_mutations/belongs_to_operations.rb +50 -0
- data/lib/lighthouse/nested_mutations/executor.rb +149 -0
- data/lib/lighthouse/nested_mutations/has_operations.rb +66 -0
- data/lib/lighthouse/nested_mutations/many_to_many_operations.rb +44 -0
- data/lib/lighthouse/nested_mutations/partitioner.rb +125 -0
- data/lib/lighthouse/nested_mutations.rb +210 -0
- data/lib/lighthouse/pagination.rb +221 -0
- data/lib/lighthouse/persisted_queries.rb +167 -0
- data/lib/lighthouse/railtie.rb +26 -0
- data/lib/lighthouse/rb_lighthouse.rb +50 -29
- data/lib/lighthouse/reference_resolver.rb +13 -15
- data/lib/lighthouse/relation_batch.rb +156 -0
- data/lib/lighthouse/resolver_dispatch.rb +26 -0
- data/lib/lighthouse/schema_cache.rb +120 -0
- data/lib/lighthouse/schema_factory.rb +27 -37
- data/lib/lighthouse/schema_generator.rb +196 -239
- data/lib/lighthouse/schema_implementation.rb +51 -48
- data/lib/lighthouse/sdl_loader.rb +17 -20
- data/lib/lighthouse/support/arguments.rb +119 -0
- data/lib/lighthouse/support/authorization.rb +18 -19
- data/lib/lighthouse/support/batching.rb +16 -0
- data/lib/lighthouse/support/directive_args.rb +26 -24
- data/lib/lighthouse/support/model_resolver.rb +56 -56
- data/lib/lighthouse/support/naming.rb +55 -59
- data/lib/lighthouse/tasks/lighthouse.rake +33 -0
- data/lib/lighthouse/where_conditions/operator_map.rb +25 -29
- data/sig/lighthouse/directives.rbs +158 -0
- data/sig/lighthouse/graphql.rbs +78 -0
- data/sig/lighthouse.rbs +142 -0
- metadata +56 -64
- data/lib/lighthouse/directive_resolvers/belongs_to_many_resolver.rb +0 -56
- data/lib/lighthouse/directive_resolvers/belongs_to_resolver.rb +0 -47
- data/lib/lighthouse/directive_resolvers/can_resolver.rb +0 -86
- data/lib/lighthouse/directive_resolvers/has_many_resolver.rb +0 -109
- data/lib/lighthouse/directive_resolvers/has_one_resolver.rb +0 -43
- 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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
relation = relation.offset(offset) if offset > 0
|
|
88
|
-
relation = relation.limit(limit) if limit
|
|
48
|
+
protected
|
|
89
49
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
66
|
+
decorate_relation(obj.association(name.to_sym).scope, args, ctx)
|
|
67
|
+
end
|
|
122
68
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
16
|
+
resolver = lambda do |obj, _arguments, _ctx|
|
|
17
|
+
next nil if obj.nil?
|
|
21
18
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|