lighthouse-graphql 0.4.0 → 0.6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +256 -1
- data/README.md +26 -15
- data/docs/caching.md +4 -3
- data/docs/configuration.md +16 -12
- data/docs/directives.md +294 -4
- data/docs/generators.md +84 -0
- data/docs/getting-started.md +93 -60
- data/docs/performance.md +2 -2
- data/docs/relationships.md +47 -4
- data/docs/relay.md +200 -0
- data/docs/resolvers.md +326 -34
- data/docs/security.md +73 -8
- data/docs/subscriptions.md +16 -9
- data/docs/types.md +218 -0
- data/docs/uploads.md +107 -0
- data/lib/generators/lighthouse/base_classes.rb +27 -49
- data/lib/generators/lighthouse/install/graphql_ruby_install.rb +113 -0
- data/lib/generators/lighthouse/install/install_generator.rb +125 -45
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +10 -8
- data/lib/lighthouse/async_mutation_job.rb +17 -0
- data/lib/lighthouse/caching.rb +9 -3
- data/lib/lighthouse/complexity.rb +5 -4
- data/lib/lighthouse/config.rb +12 -18
- data/lib/lighthouse/controller.rb +53 -12
- data/lib/lighthouse/dataloader_sources/preload_loader.rb +16 -0
- data/lib/lighthouse/dataloader_sources/relation_counts_loader.rb +46 -0
- data/lib/lighthouse/default_resolve_dispatcher.rb +26 -0
- data/lib/lighthouse/directive_resolvers/all_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/argument_binding.rb +61 -0
- data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +220 -0
- data/lib/lighthouse/directive_resolvers/association_resolvers.rb +2 -2
- data/lib/lighthouse/directive_resolvers/async_resolver.rb +90 -0
- data/lib/lighthouse/directive_resolvers/base.rb +63 -14
- data/lib/lighthouse/directive_resolvers/builder_applier.rb +89 -19
- data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +1 -1
- data/lib/lighthouse/directive_resolvers/can_resolvers.rb +1 -1
- data/lib/lighthouse/directive_resolvers/convention_resolver.rb +2 -1
- data/lib/lighthouse/directive_resolvers/count_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/field_resolver.rb +35 -7
- data/lib/lighthouse/directive_resolvers/first_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +59 -0
- data/lib/lighthouse/directive_resolvers/lazy_load_resolver.rb +61 -0
- data/lib/lighthouse/directive_resolvers/limit_resolver.rb +59 -0
- data/lib/lighthouse/directive_resolvers/method_resolver.rb +38 -3
- data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/namespaced_resolver.rb +31 -0
- data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/resolution.rb +19 -4
- data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +2 -1
- data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +136 -0
- data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +87 -0
- data/lib/lighthouse/directive_resolvers/with_count_resolver.rb +43 -0
- data/lib/lighthouse/directive_resolvers/with_resolver.rb +42 -0
- data/lib/lighthouse/directives/arguments/more_filters.rb +215 -0
- data/lib/lighthouse/directives/definitions.rb +52 -0
- data/lib/lighthouse/directives/model_directive.rb +131 -0
- data/lib/lighthouse/directives/node_directive.rb +134 -0
- data/lib/lighthouse/directives/pagination_manipulator.rb +1 -1
- data/lib/lighthouse/directives/registry.rb +21 -21
- data/lib/lighthouse/directives/visibility_directive.rb +71 -0
- data/lib/lighthouse/enum_directive.rb +41 -0
- data/lib/lighthouse/field_resolver_stack.rb +56 -6
- data/lib/lighthouse/generated_resolvers.rb +13 -8
- data/lib/lighthouse/global_id.rb +57 -0
- data/lib/lighthouse/graphql/version.rb +1 -1
- data/lib/lighthouse/graphql_ruby_patches.rb +45 -0
- data/lib/lighthouse/multipart_request.rb +43 -0
- data/lib/lighthouse/node_registry.rb +68 -0
- data/lib/lighthouse/pagination.rb +3 -0
- data/lib/lighthouse/rb_lighthouse.rb +34 -0
- data/lib/lighthouse/relation_counts.rb +29 -0
- data/lib/lighthouse/resolve_info.rb +80 -0
- data/lib/lighthouse/resolver_bases.rb +25 -4
- data/lib/lighthouse/resolver_dispatch.rb +51 -11
- data/lib/lighthouse/root_types.rb +117 -0
- data/lib/lighthouse/scalar_coercion.rb +72 -0
- data/lib/lighthouse/schema_cache.rb +2 -1
- data/lib/lighthouse/schema_generator.rb +13 -11
- data/lib/lighthouse/schema_implementation.rb +4 -8
- data/lib/lighthouse/support/environment.rb +13 -0
- data/lib/lighthouse/support/model_resolver.rb +7 -5
- data/lib/lighthouse/support/preload.rb +49 -0
- data/lib/lighthouse/type.rb +103 -0
- data/lib/lighthouse/type_resolution.rb +150 -0
- data/lib/lighthouse/upload.rb +28 -0
- data/lib/lighthouse/upload_store.rb +57 -0
- data/sig/lighthouse/directives.rbs +3 -2
- data/sig/lighthouse.rbs +10 -2
- metadata +40 -4
- data/lib/generators/lighthouse/install/templates/schema.rb.tt +0 -22
- data/lib/generators/lighthouse/mutation_generator.rb +0 -31
- data/lib/generators/lighthouse/query_generator.rb +0 -30
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# What @bind(class:) puts in place of an argument's value: the record of a model
|
|
4
|
+
# whose `column` matches it (a list's records, for a list), eager-loading `with`;
|
|
5
|
+
# or what a class's `call(value, context)` returns. A value that matches nothing
|
|
6
|
+
# is a validation error on the argument, as PHP Lighthouse's, unless `optional`.
|
|
7
|
+
class Lighthouse::GraphQL::DirectiveResolvers::ArgumentBinding
|
|
8
|
+
def initialize(directive, argument, field_def)
|
|
9
|
+
@directive = directive
|
|
10
|
+
@argument = argument
|
|
11
|
+
@field_def = field_def
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def bind(value, ctx)
|
|
15
|
+
klass = Lighthouse::Support::ModelResolver.constantize(arg('class'))
|
|
16
|
+
raise Lighthouse::DefinitionError, "@bind(class: #{arg('class').inspect}) on #{@argument.name} names no class." unless klass
|
|
17
|
+
return call(klass, value, ctx) unless model?(klass)
|
|
18
|
+
|
|
19
|
+
value.is_a?(Array) ? records(klass, value) : record(klass, value)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def arg(name)
|
|
25
|
+
Lighthouse::Support::DirectiveArgs.literal(@directive, name)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def model?(klass)
|
|
29
|
+
defined?(::ActiveRecord::Base) && klass.is_a?(Class) && klass < ::ActiveRecord::Base
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call(klass, value, ctx)
|
|
33
|
+
bound = (klass.respond_to?(:call) ? klass : klass.new).call(value, ctx)
|
|
34
|
+
bound.nil? ? missing : bound
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def scope(klass)
|
|
38
|
+
with = Array(arg('with'))
|
|
39
|
+
with.empty? ? klass.all : klass.preload(*Lighthouse::Support::Preload.associations(with))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def record(klass, value)
|
|
43
|
+
scope(klass).find_by(column => value) || missing
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def records(klass, values)
|
|
47
|
+
found = scope(klass).where(column => values).index_by { |record| record.public_send(column).to_s }
|
|
48
|
+
bound = values.map { |value| found[value.to_s] }
|
|
49
|
+
bound.include?(nil) ? missing(bound) : bound
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def column
|
|
53
|
+
arg('column') || 'id'
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def missing(partial = nil)
|
|
57
|
+
return partial if arg('optional')
|
|
58
|
+
|
|
59
|
+
raise Lighthouse::ValidationError.new(@field_def.name, @argument.name => ["The selected #{@argument.name} is invalid."])
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
# Rewrites the arguments a field receives, before it resolves, as PHP
|
|
6
|
+
# Lighthouse's argument directives do. On an argument or an input field, in the
|
|
7
|
+
# order the SDL lists them:
|
|
8
|
+
#
|
|
9
|
+
# @globalId(decode:) a Relay global id, decoded ([type, id], the type or the id)
|
|
10
|
+
# @trim leading and trailing whitespace removed
|
|
11
|
+
# @hash the value hashed with BCrypt, for a password column
|
|
12
|
+
# @drop the value removed: the resolver never sees it
|
|
13
|
+
# @bind(class:) the value replaced by the record(s) it identifies
|
|
14
|
+
# @upload the uploaded file stored with ActiveStorage (UploadStore), its blob's signed id passed
|
|
15
|
+
#
|
|
16
|
+
# type Mutation {
|
|
17
|
+
# signUp(email: String! @trim, password: String! @trim @hash, captcha: String @drop): User! @create
|
|
18
|
+
# assign(task: ID! @bind(class: "Task"), user: ID! @bind(class: "User")): Task! @field(resolver: "Assign")
|
|
19
|
+
# }
|
|
20
|
+
#
|
|
21
|
+
# @trim on a field trims every string it takes, nested inputs included. Lists
|
|
22
|
+
# and input objects are walked; input objects that change are copied, never
|
|
23
|
+
# mutated. Not a directive resolver: FieldResolverStack installs it twice, as
|
|
24
|
+
# two phases. :rewrite (@globalId, @trim, @drop) is outermost, so @guard, the
|
|
25
|
+
# @can* family and @validator see the rewritten values. :effects (@hash, @bind,
|
|
26
|
+
# @upload) runs once those have let the call through, around whatever resolves
|
|
27
|
+
# the field: a validator sees the password before it is hashed, and nothing is
|
|
28
|
+
# stored or looked up for a call that is refused.
|
|
29
|
+
class Lighthouse::GraphQL::DirectiveResolvers::ArgumentTransformsResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
30
|
+
PHASES = { rewrite: %w[globalId trim drop], effects: %w[hash bind upload] }.freeze
|
|
31
|
+
NAMES = PHASES.values.flatten.freeze
|
|
32
|
+
DROP = Object.new.freeze
|
|
33
|
+
|
|
34
|
+
DEFINITIONS = {
|
|
35
|
+
'trim' => <<~GRAPHQL.chomp,
|
|
36
|
+
"""
|
|
37
|
+
Remove whitespace from the beginning and end of a given input.
|
|
38
|
+
On a field, of every string input it takes.
|
|
39
|
+
"""
|
|
40
|
+
directive @trim on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | FIELD_DEFINITION
|
|
41
|
+
GRAPHQL
|
|
42
|
+
'hash' => <<~GRAPHQL.chomp,
|
|
43
|
+
"""
|
|
44
|
+
Hash an argument value with BCrypt (has_secure_password's), e.g. a password.
|
|
45
|
+
"""
|
|
46
|
+
directive @hash on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
|
|
47
|
+
GRAPHQL
|
|
48
|
+
'drop' => <<~GRAPHQL.chomp,
|
|
49
|
+
"""
|
|
50
|
+
Ignore the user given value, don't pass it to the resolver.
|
|
51
|
+
"""
|
|
52
|
+
directive @drop on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
|
|
53
|
+
GRAPHQL
|
|
54
|
+
'bind' => <<~GRAPHQL.chomp,
|
|
55
|
+
"""
|
|
56
|
+
Replace argument values with the corresponding model (or some other value)
|
|
57
|
+
before passing them to the resolver.
|
|
58
|
+
"""
|
|
59
|
+
directive @bind(
|
|
60
|
+
"A model class, or a class whose `call(value, context)` returns what the value stands for."
|
|
61
|
+
class: String!
|
|
62
|
+
"The column the value is matched against, for a model."
|
|
63
|
+
column: String = "id"
|
|
64
|
+
"Relations to eager-load on the bound records."
|
|
65
|
+
with: [String!] = []
|
|
66
|
+
"Pass null instead of failing when no record matches."
|
|
67
|
+
optional: Boolean = false
|
|
68
|
+
) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
|
|
69
|
+
GRAPHQL
|
|
70
|
+
'upload' => <<~GRAPHQL.chomp
|
|
71
|
+
"""
|
|
72
|
+
Store the file an Upload argument carries with ActiveStorage, and pass along the
|
|
73
|
+
signed id of its blob instead, which an attachment attribute (has_one_attached)
|
|
74
|
+
attaches. This does not change the schema from a client perspective.
|
|
75
|
+
"""
|
|
76
|
+
directive @upload(
|
|
77
|
+
"The ActiveStorage service to store the file on (config/storage.yml). Defaults to config.active_storage.service."
|
|
78
|
+
disk: String
|
|
79
|
+
"The directory the stored file's key starts with."
|
|
80
|
+
path: String = "/"
|
|
81
|
+
"Whether the file is public. ActiveStorage sets that per service, so it must agree with the service's."
|
|
82
|
+
public: Boolean = false
|
|
83
|
+
) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
|
|
84
|
+
GRAPHQL
|
|
85
|
+
}.freeze
|
|
86
|
+
|
|
87
|
+
# Does the field take anything to rewrite in `phase`: @trim on the field
|
|
88
|
+
# itself, or an argument at any depth of input types carrying one of the
|
|
89
|
+
# phase's directives?
|
|
90
|
+
def self.transforms?(field_def, phase)
|
|
91
|
+
(phase == :rewrite && trims_all?(field_def)) || carried?(field_def.arguments, PHASES.fetch(phase))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.trims_all?(field_def)
|
|
95
|
+
(field_def.ast_node&.directives || []).any? { |d| d.name == 'trim' }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.carried?(argument_defs, names, seen = Set.new)
|
|
99
|
+
argument_defs.each_value.any? do |argument|
|
|
100
|
+
type = argument.type.unwrap
|
|
101
|
+
directives(argument, names).any? || (type.kind.input_object? && seen.add?(type.graphql_name) && carried?(type.arguments, names, seen))
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.directives(argument, names = NAMES)
|
|
106
|
+
(argument.ast_node&.directives || []).select { |d| names.include?(d.name) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def define_resolver(target_type_class)
|
|
110
|
+
me = self
|
|
111
|
+
argument_defs = @field_def.arguments
|
|
112
|
+
trim_all = phase == :rewrite && self.class.trims_all?(@field_def)
|
|
113
|
+
inner = capture_inner(target_type_class)
|
|
114
|
+
|
|
115
|
+
install(target_type_class, lambda do |obj, args, ctx|
|
|
116
|
+
inner.call(obj, args.is_a?(Hash) ? me.transform_hash(argument_defs, args, ctx, trim_all) : args, ctx)
|
|
117
|
+
end)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The values with every argument's directives applied; a dropped one removed.
|
|
121
|
+
def transform_hash(argument_defs, values, ctx, trim_all)
|
|
122
|
+
by_keyword = Lighthouse::Support::Arguments.keyword_index(argument_defs)
|
|
123
|
+
values.each_with_object({}) do |(key, value), result|
|
|
124
|
+
transformed = transform_argument(by_keyword[key.to_sym], value, ctx, trim_all)
|
|
125
|
+
result[key] = transformed unless transformed.equal?(DROP)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def phase
|
|
132
|
+
@opts.fetch(:phase, :rewrite)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def transform_argument(argument, value, ctx, trim_all)
|
|
136
|
+
return value if argument.nil?
|
|
137
|
+
|
|
138
|
+
value = nested(argument.type, value, ctx, trim_all)
|
|
139
|
+
value = trim(value) if trim_all
|
|
140
|
+
self.class.directives(argument, PHASES.fetch(phase)).reduce(value) do |current, directive|
|
|
141
|
+
break DROP if current.equal?(DROP)
|
|
142
|
+
|
|
143
|
+
apply(directive, argument, current, ctx)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Each directive's transform, a method taking (directive, argument, value, context).
|
|
148
|
+
TRANSFORMS = { 'globalId' => :decode_global_id, 'trim' => :trim_value, 'hash' => :hash_value, 'bind' => :bind, 'upload' => :upload }.freeze
|
|
149
|
+
|
|
150
|
+
def apply(directive, argument, value, ctx)
|
|
151
|
+
return DROP if directive.name == 'drop'
|
|
152
|
+
return value if value.nil?
|
|
153
|
+
|
|
154
|
+
send(TRANSFORMS.fetch(directive.name), directive, argument, value, ctx)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def decode_global_id(directive, _argument, value, ctx)
|
|
158
|
+
decode(value, Lighthouse::Support::DirectiveArgs.literal(directive, 'decode') || 'ARRAY', ctx)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def trim_value(_directive, _argument, value, _ctx) = trim(value)
|
|
162
|
+
|
|
163
|
+
def hash_value(_directive, _argument, value, _ctx) = hash(value)
|
|
164
|
+
|
|
165
|
+
def upload(directive, argument, value, ctx)
|
|
166
|
+
return value.map { |item| upload(directive, argument, item, ctx) } if value.is_a?(Array)
|
|
167
|
+
|
|
168
|
+
options = %w[disk path public].to_h { |name| [name.to_sym, Lighthouse::Support::DirectiveArgs.literal(directive, name)] }
|
|
169
|
+
Lighthouse::GraphQL::UploadStore.call(value, argument: argument.graphql_name, **options)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def decode(value, mode, ctx = nil)
|
|
173
|
+
return value.map { |item| decode(item, mode, ctx) } if value.is_a?(Array)
|
|
174
|
+
|
|
175
|
+
type, id = Lighthouse::GraphQL::GlobalId.decode!(value, ctx)
|
|
176
|
+
{ 'TYPE' => type, 'ID' => id }.fetch(mode.to_s, [type, id])
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def trim(value)
|
|
180
|
+
case value
|
|
181
|
+
when String then value.strip
|
|
182
|
+
when Array then value.map { |item| trim(item) }
|
|
183
|
+
else value
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def hash(value)
|
|
188
|
+
return value.map { |item| hash(item) } if value.is_a?(Array)
|
|
189
|
+
|
|
190
|
+
begin
|
|
191
|
+
require 'bcrypt'
|
|
192
|
+
rescue LoadError
|
|
193
|
+
raise Lighthouse::DefinitionError, '@hash needs the bcrypt gem (gem "bcrypt", as has_secure_password does).'
|
|
194
|
+
end
|
|
195
|
+
BCrypt::Password.create(value.to_s).to_s
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def bind(directive, argument, value, ctx)
|
|
199
|
+
Lighthouse::GraphQL::DirectiveResolvers::ArgumentBinding.new(directive, argument, @field_def).bind(value, ctx)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# An input object's own arguments, transformed in a copy.
|
|
203
|
+
def nested(type, value, ctx, trim_all)
|
|
204
|
+
type = type.unwrap
|
|
205
|
+
return value unless type.kind.input_object? && !value.nil?
|
|
206
|
+
return value.map { |item| nested(type, item, ctx, trim_all) } if value.is_a?(Array)
|
|
207
|
+
|
|
208
|
+
transform_input(type, value, ctx, trim_all)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def transform_input(type, value, ctx, trim_all)
|
|
212
|
+
values = value.respond_to?(:to_kwargs) ? value.to_kwargs : value
|
|
213
|
+
return value unless values.is_a?(Hash)
|
|
214
|
+
|
|
215
|
+
transformed = transform_hash(type.arguments, values, ctx, trim_all)
|
|
216
|
+
return transformed unless value.respond_to?(:to_kwargs)
|
|
217
|
+
|
|
218
|
+
value.dup.tap { |copy| copy.instance_variable_set(:@ruby_style_hash, transformed) }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -92,8 +92,8 @@ module Lighthouse::GraphQL::DirectiveResolvers
|
|
|
92
92
|
|
|
93
93
|
# The field's scopes and filters on the association's query, for one
|
|
94
94
|
# parent or a RelationBatch of them.
|
|
95
|
-
def decorate_relation(relation, args,
|
|
96
|
-
apply_where_filters(scoped(relation), args)
|
|
95
|
+
def decorate_relation(relation, args, ctx)
|
|
96
|
+
apply_where_filters(scoped(relation), args, ctx)
|
|
97
97
|
end
|
|
98
98
|
|
|
99
99
|
protected
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
# @async — run a mutation in a queued job and answer `true` at once, as PHP
|
|
6
|
+
# Lighthouse's directive. What decides whether it may run does so in the
|
|
7
|
+
# request: @guard, the @can* family, @validator and the argument directives, so
|
|
8
|
+
# the client still gets their errors. What resolves it (@create, a Mutations::
|
|
9
|
+
# class, ...) runs in Lighthouse::GraphQL::AsyncMutationJob, through ActiveJob,
|
|
10
|
+
# with the arguments and the context values ActiveJob can serialize (records
|
|
11
|
+
# included, through GlobalID; a request or a file is left out).
|
|
12
|
+
#
|
|
13
|
+
# type Mutation {
|
|
14
|
+
# importFeed(url: String!): Boolean! @guard @async(queue: "imports")
|
|
15
|
+
# }
|
|
16
|
+
#
|
|
17
|
+
# The field must be a Mutation field returning Boolean, and the schema a named
|
|
18
|
+
# constant, which the job looks up.
|
|
19
|
+
class Lighthouse::GraphQL::DirectiveResolvers::AsyncDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
20
|
+
def self.directive_name = 'async'
|
|
21
|
+
|
|
22
|
+
def self.definition
|
|
23
|
+
<<~GRAPHQL.chomp
|
|
24
|
+
"""
|
|
25
|
+
Defer the execution of mutations to queued jobs.
|
|
26
|
+
The field answers true once the job is queued.
|
|
27
|
+
"""
|
|
28
|
+
directive @async(
|
|
29
|
+
"The name of the queue to dispatch the job on."
|
|
30
|
+
queue: String
|
|
31
|
+
) on FIELD_DEFINITION
|
|
32
|
+
GRAPHQL
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# What resolves `field` of `type` below @async, for the job to call.
|
|
36
|
+
def self.inner(type, field)
|
|
37
|
+
(type.instance_variable_get(:@lighthouse_async_inners) || {}).fetch(field.to_s) do
|
|
38
|
+
raise Lighthouse::DefinitionError, "#{type.graphql_name}.#{field} has no @async."
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def define_resolver(target_type_class)
|
|
43
|
+
validate!(target_type_class)
|
|
44
|
+
inner = capture_inner(target_type_class)
|
|
45
|
+
inners = target_type_class.instance_variable_get(:@lighthouse_async_inners) || {}
|
|
46
|
+
target_type_class.instance_variable_set(:@lighthouse_async_inners, inners.merge(@field_def.name => inner))
|
|
47
|
+
me = self
|
|
48
|
+
|
|
49
|
+
install(target_type_class, lambda do |_obj, args, ctx|
|
|
50
|
+
me.enqueue(target_type_class.graphql_name, args, ctx)
|
|
51
|
+
true
|
|
52
|
+
end)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def enqueue(type_name, args, ctx)
|
|
56
|
+
schema_name = ctx.schema.name
|
|
57
|
+
raise Lighthouse::DefinitionError, '@async needs a schema assigned to a constant (AppSchema = ...), for the job to find.' unless schema_name
|
|
58
|
+
|
|
59
|
+
job = Lighthouse::GraphQL::AsyncMutationJob
|
|
60
|
+
job = job.set(queue: directive_arg('queue')) if directive_arg('queue')
|
|
61
|
+
job.perform_later(schema_name, type_name, @field_def.name, serializable(Lighthouse::Support::Arguments.plain(args)), context_values(ctx))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def validate!(target_type_class)
|
|
67
|
+
return if @opts[:root_kind] == :mutations && @field_def.type.unwrap.graphql_name == 'Boolean'
|
|
68
|
+
|
|
69
|
+
raise Lighthouse::DefinitionError,
|
|
70
|
+
"@async on #{target_type_class.graphql_name}.#{@field_def.name}: only a Mutation field returning Boolean can run in a job."
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The context's values ActiveJob can serialize.
|
|
74
|
+
def context_values(ctx)
|
|
75
|
+
values = ctx.respond_to?(:to_h) ? ctx.to_h : {}
|
|
76
|
+
serializable(values.reject { |key, _| key.to_s.start_with?('graphql_', 'lighthouse_') || EXECUTION_KEYS.include?(key) })
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# graphql-ruby's own context keys, which mean nothing outside the request.
|
|
80
|
+
EXECUTION_KEYS = %i[current_path current_field current_object current_arguments].freeze
|
|
81
|
+
|
|
82
|
+
def serializable(hash)
|
|
83
|
+
hash.select do |_, value|
|
|
84
|
+
::ActiveJob::Arguments.serialize([value])
|
|
85
|
+
true
|
|
86
|
+
rescue StandardError
|
|
87
|
+
false
|
|
88
|
+
end.transform_keys(&:to_sym)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -12,7 +12,8 @@ class Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
|
12
12
|
raise NotImplementedError
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# `ctx`: the query's context, for @builder on an argument.
|
|
16
|
+
def apply_where_filters(relation, args, ctx = nil)
|
|
16
17
|
return relation unless relation.respond_to?(:where)
|
|
17
18
|
|
|
18
19
|
args_h = args.respond_to?(:to_h) ? args.to_h : (args || {})
|
|
@@ -22,7 +23,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
|
22
23
|
relation = apply_where_has_conditions(relation, arg_node, args_h)
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
apply_arg_builders(relation, args_h)
|
|
26
|
+
apply_arg_builders(relation, args_h, ctx)
|
|
26
27
|
rescue ::GraphQL::ExecutionError
|
|
27
28
|
raise
|
|
28
29
|
rescue StandardError => e
|
|
@@ -33,16 +34,13 @@ class Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
|
33
34
|
# Compose argument-builder directives (@eq, @neq, @in, @notIn, @like,
|
|
34
35
|
# @where, @orderBy) onto the relation, in SDL argument order, for each
|
|
35
36
|
# field argument that carries one and that the client supplied a value
|
|
36
|
-
# for.
|
|
37
|
-
def apply_arg_builders(relation, args_h)
|
|
37
|
+
# for — and for the input fields of an argument carrying @nest.
|
|
38
|
+
def apply_arg_builders(relation, args_h, ctx = nil)
|
|
38
39
|
return relation unless relation.respond_to?(:where)
|
|
39
40
|
return relation unless @field_def.ast_node
|
|
40
41
|
|
|
41
|
-
arg_builders.each do |arg_node, dnode, builder_class|
|
|
42
|
-
|
|
43
|
-
next if value.nil?
|
|
44
|
-
|
|
45
|
-
relation = builder_class.new(arg_node, dnode).handle_builder(relation, value)
|
|
42
|
+
arg_builders.each do |path, arg_node, dnode, builder_class|
|
|
43
|
+
relation = apply_arg_builder(relation, args_h, ctx, [path, arg_node, dnode, builder_class])
|
|
46
44
|
end
|
|
47
45
|
|
|
48
46
|
relation
|
|
@@ -136,13 +134,64 @@ class Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
|
136
134
|
end
|
|
137
135
|
|
|
138
136
|
# Every (argument, directive, builder class) on the field, in SDL order.
|
|
137
|
+
# One argument directive: @builder whenever the client sent the argument,
|
|
138
|
+
# null included (as PHP Lighthouse's); the others when its value is not null.
|
|
139
|
+
def apply_arg_builder(relation, args_h, ctx, (path, arg_node, dnode, builder_class))
|
|
140
|
+
if builder_class == :builder
|
|
141
|
+
return relation unless arg_present?(args_h, path)
|
|
142
|
+
|
|
143
|
+
return Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.apply_argument(relation, dnode, dig_arg(args_h, path), ctx)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
value = dig_arg(args_h, path)
|
|
147
|
+
value.nil? ? relation : builder_class.new(arg_node, dnode).handle_builder(relation, value)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Did the client send the argument at `path`, even as null?
|
|
151
|
+
def arg_present?(args_h, path)
|
|
152
|
+
parent = path.size == 1 ? args_h : dig_arg(args_h, path[0...-1])
|
|
153
|
+
hash = parent.respond_to?(:to_h) && !parent.is_a?(Array) ? parent.to_h : parent
|
|
154
|
+
hash.is_a?(Hash) && Lighthouse::Support::Naming.candidate_keys(path.last).any? { |key| hash.key?(key) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# [path, node, directive, builder class] for every argument-builder
|
|
158
|
+
# directive, in SDL order. An argument carrying @nest adds its input type's
|
|
159
|
+
# fields', at any depth, with their path into the value.
|
|
139
160
|
def arg_builders
|
|
140
|
-
registry = Lighthouse::DirectiveRegistry.default
|
|
141
161
|
(@field_def.ast_node.arguments || []).flat_map do |arg_node|
|
|
142
|
-
(arg_node.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
162
|
+
builders_of(arg_node, [arg_node.name], @field_def.arguments[arg_node.name])
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def builders_of(node, path, argument)
|
|
167
|
+
registry = Lighthouse::DirectiveRegistry.default
|
|
168
|
+
own = (node.directives || []).filter_map do |dnode|
|
|
169
|
+
builder_class = dnode.name == 'builder' ? :builder : registry.arg_builder_class(dnode.name)
|
|
170
|
+
[path, node, dnode, builder_class] if builder_class
|
|
171
|
+
end
|
|
172
|
+
own + (argument && nest?(node) ? nested_builders(argument, path) : [])
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def nest?(node)
|
|
176
|
+
(node.directives || []).any? { |d| d.name == 'nest' }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def nested_builders(argument, path)
|
|
180
|
+
type = argument.type.unwrap
|
|
181
|
+
return [] unless type.kind.input_object?
|
|
182
|
+
|
|
183
|
+
type.arguments.each_value.flat_map do |field|
|
|
184
|
+
field.ast_node ? builders_of(field.ast_node, path + [field.ast_node.name], field) : []
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# The client's value at `path` (an argument, then input fields), or nil.
|
|
189
|
+
def dig_arg(args_h, path)
|
|
190
|
+
path.reduce(args_h) do |value, name|
|
|
191
|
+
hash = value.respond_to?(:to_h) && !value.is_a?(Array) ? value.to_h : value
|
|
192
|
+
break nil unless hash.is_a?(Hash)
|
|
193
|
+
|
|
194
|
+
Lighthouse::Support::Naming.fetch_arg(hash, name)
|
|
146
195
|
end
|
|
147
196
|
end
|
|
148
197
|
end
|
|
@@ -2,39 +2,99 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'base'
|
|
4
4
|
|
|
5
|
+
# The field-level query directives, in SDL order: @builder classes, and
|
|
6
|
+
# @whereAuth, which keeps the records the signed-in user owns. @builder on an
|
|
7
|
+
# argument is applied with the other argument directives (apply_argument).
|
|
8
|
+
#
|
|
9
|
+
# A builder is `class:` and `method:` (default `call`), or PHP Lighthouse's
|
|
10
|
+
# `method: "Class@method"`, looked up in Config.namespaces[:builders]. It gets
|
|
11
|
+
# the relation, a second value and the context, and returns the relation:
|
|
12
|
+
#
|
|
13
|
+
# on a field (relation, args, context), or the directive's `value:` instead of args
|
|
14
|
+
# on an argument (relation, the argument's value, context), whenever the client sends
|
|
15
|
+
# the argument, null included
|
|
5
16
|
class Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier
|
|
6
|
-
DEFINITION =
|
|
17
|
+
DEFINITION = <<~GRAPHQL.chomp
|
|
18
|
+
"""
|
|
19
|
+
Manipulate the query builder with a method.
|
|
20
|
+
"""
|
|
21
|
+
directive @builder(
|
|
22
|
+
"The builder class, in the builders namespace."
|
|
23
|
+
class: String
|
|
24
|
+
"The method to call: a name (default `call`), or a class and a method separated by `@`."
|
|
25
|
+
method: String
|
|
26
|
+
"On a field, the value the method gets after the relation, instead of the arguments."
|
|
27
|
+
value: BuilderValue
|
|
28
|
+
) repeatable on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | FIELD_DEFINITION
|
|
29
|
+
GRAPHQL
|
|
30
|
+
|
|
31
|
+
TYPE_DEFINITIONS = {
|
|
32
|
+
'BuilderValue' => <<~GRAPHQL.chomp
|
|
33
|
+
"Any constant literal value: https://graphql.github.io/graphql-spec/draft/#sec-Input-Values"
|
|
34
|
+
scalar BuilderValue
|
|
35
|
+
GRAPHQL
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
WHERE_AUTH_DEFINITION = <<~GRAPHQL.chomp
|
|
39
|
+
"""
|
|
40
|
+
Filter a type to only return instances owned by the current user.
|
|
41
|
+
"""
|
|
42
|
+
directive @whereAuth(
|
|
43
|
+
"The relation of the model that links it to the user: a belongs_to, a has_many, a :through."
|
|
44
|
+
relation: String!
|
|
45
|
+
"Accepted for PHP Lighthouse parity: the user is Config.authenticated_user's."
|
|
46
|
+
guards: [String!]
|
|
47
|
+
) on FIELD_DEFINITION
|
|
48
|
+
GRAPHQL
|
|
49
|
+
|
|
50
|
+
DEFINITIONS = { 'builder' => DEFINITION, 'whereAuth' => WHERE_AUTH_DEFINITION }.freeze
|
|
7
51
|
|
|
8
52
|
def self.apply(relation, field_def_ast_node, args, ctx, resolver_instance)
|
|
9
53
|
return relation unless field_def_ast_node&.directives
|
|
10
54
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
55
|
+
field_def_ast_node.directives.each do |directive_node|
|
|
56
|
+
case directive_node.name
|
|
57
|
+
when 'builder' then relation = apply_directive(relation, directive_node, args, ctx, resolver_instance)
|
|
58
|
+
when 'whereAuth' then relation = where_auth(relation, directive_node, ctx)
|
|
59
|
+
end
|
|
16
60
|
end
|
|
17
61
|
|
|
18
62
|
relation
|
|
19
63
|
end
|
|
20
64
|
|
|
65
|
+
# The records whose `relation:` links them to the signed-in user; none for a guest.
|
|
66
|
+
def self.where_auth(relation, directive_node, ctx)
|
|
67
|
+
user = Lighthouse::Support::Authorization.authenticated_user(ctx)
|
|
68
|
+
return relation.none unless user
|
|
69
|
+
|
|
70
|
+
model = relation.klass
|
|
71
|
+
name = extract_directive_arg(directive_node, 'relation').to_sym
|
|
72
|
+
reflection = model.reflect_on_association(name)
|
|
73
|
+
raise Lighthouse::DefinitionError, "@whereAuth: #{model} has no relation #{name}." unless reflection
|
|
74
|
+
return relation.where(name => user) if reflection.belongs_to?
|
|
75
|
+
|
|
76
|
+
owned = model.unscoped.joins(name).where(reflection.klass.table_name => { reflection.klass.primary_key => user.id })
|
|
77
|
+
relation.where(model.primary_key => owned.select(model.arel_table[model.primary_key]))
|
|
78
|
+
end
|
|
79
|
+
|
|
21
80
|
def self.apply_directive(relation, directive_node, args, ctx, _resolver_instance)
|
|
22
|
-
|
|
23
|
-
|
|
81
|
+
value = directive_node.arguments&.any? { |a| a.name == 'value' } ? extract_directive_arg(directive_node, 'value') : args
|
|
82
|
+
call_builder(relation, directive_node, value, ctx)
|
|
83
|
+
end
|
|
24
84
|
|
|
25
|
-
|
|
85
|
+
# @builder on an argument the client sent: the method gets its value.
|
|
86
|
+
def self.apply_argument(relation, directive_node, value, ctx)
|
|
87
|
+
call_builder(relation, directive_node, value, ctx)
|
|
88
|
+
end
|
|
26
89
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
builder_class = Lighthouse::Support::ModelResolver.constantize(
|
|
30
|
-
class_name, namespaces: Lighthouse::Config.namespaces[:builders]
|
|
31
|
-
)
|
|
90
|
+
def self.call_builder(relation, directive_node, value, ctx)
|
|
91
|
+
builder_class, method_name = reference(directive_node)
|
|
32
92
|
return relation unless builder_class
|
|
33
93
|
|
|
34
94
|
receiver = builder_receiver(builder_class, method_name)
|
|
35
95
|
return relation unless receiver
|
|
36
96
|
|
|
37
|
-
receiver.public_send(method_name, relation,
|
|
97
|
+
receiver.public_send(method_name, relation, value, ctx) || relation
|
|
38
98
|
rescue ::GraphQL::ExecutionError
|
|
39
99
|
# Client-facing errors (e.g. authorization / scoping guards raised by a
|
|
40
100
|
# builder) MUST propagate — never silently fall back to the unscoped
|
|
@@ -45,11 +105,21 @@ class Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier
|
|
|
45
105
|
relation
|
|
46
106
|
end
|
|
47
107
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
108
|
+
# [builder class, method name] from `class:` and `method:`, or from
|
|
109
|
+
# `method: "Class@method"`. Builders are not resolvers: they live in their
|
|
110
|
+
# own namespace (Config.namespaces[:builders], app/graphql/builders).
|
|
111
|
+
def self.reference(directive_node)
|
|
112
|
+
class_name = extract_directive_arg(directive_node, 'class')
|
|
113
|
+
method_name = extract_directive_arg(directive_node, 'method')
|
|
114
|
+
class_name, method_name = method_name.to_s.split('@', 2) if class_name.nil? && method_name
|
|
115
|
+
return [nil, nil] unless class_name
|
|
116
|
+
|
|
117
|
+
builder_class = Lighthouse::Support::ModelResolver.constantize(class_name, namespaces: Lighthouse::Config.namespaces[:builders])
|
|
118
|
+
[builder_class, method_name.presence || 'call']
|
|
119
|
+
end
|
|
51
120
|
|
|
52
|
-
|
|
121
|
+
def self.extract_directive_arg(directive_node, name)
|
|
122
|
+
Lighthouse::Support::DirectiveArgs.literal(directive_node, name)
|
|
53
123
|
end
|
|
54
124
|
|
|
55
125
|
# What answers the builder method: the class itself, else an instance
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require_relative 'base'
|
|
4
4
|
|
|
5
5
|
module Lighthouse::GraphQL::DirectiveResolvers
|
|
6
|
-
# @cache — keep a field's resolved value in
|
|
6
|
+
# @cache — keep a field's resolved value in Rails.cache (Caching.store).
|
|
7
7
|
#
|
|
8
8
|
# The key covers the parent type and its key (the field marked @cacheKey,
|
|
9
9
|
# else the first `ID` field; none on Query/Mutation), the field, and its
|
|
@@ -136,7 +136,7 @@ module Lighthouse::GraphQL::DirectiveResolvers
|
|
|
136
136
|
|
|
137
137
|
# The records the field's argument filters (@eq, @where, ...) select.
|
|
138
138
|
def authorize_before(_obj, args, ctx)
|
|
139
|
-
authorize_each!(ctx, apply_where_filters(scoped(model_class.all), args).to_a, args)
|
|
139
|
+
authorize_each!(ctx, apply_where_filters(scoped(model_class.all), args, ctx).to_a, args)
|
|
140
140
|
end
|
|
141
141
|
end
|
|
142
142
|
|
|
@@ -18,6 +18,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver < Lighthouse::
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def define_resolver(target_type_class)
|
|
21
|
+
Lighthouse::GraphQL::ResolveInfo.request_lookahead(@field_def)
|
|
21
22
|
me = self
|
|
22
23
|
inner = capture_inner(target_type_class)
|
|
23
24
|
|
|
@@ -54,7 +55,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver < Lighthouse::
|
|
|
54
55
|
end
|
|
55
56
|
Lighthouse::GraphQL::ResolverBases.check!(klass, @base, location)
|
|
56
57
|
|
|
57
|
-
Lighthouse::GraphQL::ResolverDispatch.call(klass, @field_def, obj, args, ctx)
|
|
58
|
+
Lighthouse::GraphQL::ResolverDispatch.call(klass, Lighthouse::GraphQL::ResolveInfo.for(@field_def, @type_def, obj, args, ctx))
|
|
58
59
|
end
|
|
59
60
|
|
|
60
61
|
# No class: the root object may still answer (a custom root_value);
|