lighthouse-graphql 0.3.0 → 0.5.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 +182 -1
- data/README.md +18 -7
- data/docs/best-practices.md +16 -6
- data/docs/configuration.md +33 -8
- data/docs/directives.md +318 -10
- data/docs/events.md +3 -1
- data/docs/federation.md +3 -2
- data/docs/generators.md +82 -0
- data/docs/getting-started.md +66 -32
- data/docs/motivation.md +3 -2
- data/docs/relationships.md +42 -0
- data/docs/relay.md +137 -0
- data/docs/resolvers.md +338 -25
- data/docs/security.md +227 -0
- data/docs/subscriptions.md +60 -37
- data/docs/types.md +219 -0
- data/docs/uploads.md +80 -0
- data/lib/generators/lighthouse/base_classes.rb +70 -0
- data/lib/generators/lighthouse/class_generator.rb +19 -8
- data/lib/generators/lighthouse/install/install_generator.rb +96 -20
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +19 -10
- data/lib/generators/lighthouse/install/templates/schema.rb.tt +21 -2
- data/lib/generators/lighthouse/mutation_generator.rb +5 -4
- data/lib/generators/lighthouse/query_generator.rb +5 -4
- data/lib/generators/lighthouse/subscription_generator.rb +21 -16
- data/lib/lighthouse/async_mutation_job.rb +17 -0
- data/lib/lighthouse/complexity.rb +175 -0
- data/lib/lighthouse/complexity_analyzer.rb +56 -0
- data/lib/lighthouse/config.rb +16 -4
- data/lib/lighthouse/controller.rb +27 -9
- 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/can_resolvers.rb +1 -1
- data/lib/lighthouse/directive_resolvers/convention_resolver.rb +16 -5
- data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +1 -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 +57 -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 +14 -5
- data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +77 -0
- data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +81 -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 -20
- data/lib/lighthouse/directives/visibility_directive.rb +71 -0
- data/lib/lighthouse/enum_directive.rb +41 -0
- data/lib/lighthouse/events/execution_trace.rb +2 -1
- data/lib/lighthouse/events/lifecycle.rb +3 -1
- data/lib/lighthouse/field_resolver_stack.rb +59 -11
- data/lib/lighthouse/generated_resolvers.rb +13 -8
- data/lib/lighthouse/global_id.rb +31 -0
- data/lib/lighthouse/graphql/version.rb +1 -1
- 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 +36 -0
- data/lib/lighthouse/relation_counts.rb +29 -0
- data/lib/lighthouse/resolve_info.rb +80 -0
- data/lib/lighthouse/resolver_bases.rb +68 -0
- data/lib/lighthouse/resolver_dispatch.rb +36 -11
- data/lib/lighthouse/scalar_coercion.rb +72 -0
- data/lib/lighthouse/schema_cache.rb +2 -1
- data/lib/lighthouse/schema_factory.rb +0 -1
- data/lib/lighthouse/schema_generator.rb +21 -12
- data/lib/lighthouse/schema_implementation.rb +4 -8
- data/lib/lighthouse/sdl_loader.rb +0 -2
- data/lib/lighthouse/support/environment.rb +13 -0
- data/lib/lighthouse/support/model_resolver.rb +8 -6
- 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 +26 -0
- data/lib/lighthouse/upload_store.rb +32 -0
- data/sig/lighthouse/directives.rbs +3 -2
- data/sig/lighthouse/graphql.rbs +29 -0
- data/sig/lighthouse.rbs +18 -0
- metadata +43 -1
data/lib/lighthouse/config.rb
CHANGED
|
@@ -12,14 +12,16 @@ module Lighthouse
|
|
|
12
12
|
# Where each kind of class a schema refers to by name is looked up. Query and
|
|
13
13
|
# mutation fields resolve to GraphQL::Schema::Resolver / Mutation classes,
|
|
14
14
|
# builders compose onto a relation, validators check arguments: distinct
|
|
15
|
-
# contracts, so distinct namespaces (app/graphql/
|
|
15
|
+
# contracts, so distinct namespaces (app/graphql/resolvers, .../mutations, ...).
|
|
16
16
|
DEFAULT_NAMESPACES = {
|
|
17
|
-
queries: ['
|
|
17
|
+
queries: ['Resolvers'],
|
|
18
18
|
mutations: ['Mutations'],
|
|
19
19
|
builders: ['Builders'],
|
|
20
20
|
validators: ['Validators'],
|
|
21
21
|
events: ['Events'],
|
|
22
|
-
subscriptions: ['Subscriptions']
|
|
22
|
+
subscriptions: ['Subscriptions'],
|
|
23
|
+
analyzers: ['Analyzers'],
|
|
24
|
+
types: ['Types']
|
|
23
25
|
}.freeze
|
|
24
26
|
|
|
25
27
|
# Every setting reset! forgets, so the next read falls back to its default.
|
|
@@ -31,13 +33,14 @@ module Lighthouse
|
|
|
31
33
|
convert_empty_strings_to_null
|
|
32
34
|
soft_deletes
|
|
33
35
|
queue_broadcasts broadcast_queue
|
|
36
|
+
global_id upload_store
|
|
34
37
|
].freeze
|
|
35
38
|
|
|
36
39
|
class << self
|
|
37
40
|
attr_writer :sdl_folder, :resolver_namespaces, :on_error, :authenticated_user, :policy_user, :authorizer, :cache_store,
|
|
38
41
|
:transactional_mutations, :schema_cache, :watch_sdl, :persisted_queries, :persisted_queries_store, :persisted_queries_only,
|
|
39
42
|
:queue_broadcasts,
|
|
40
|
-
:convert_empty_strings_to_null, :feature_checker, :feature_actor
|
|
43
|
+
:convert_empty_strings_to_null, :feature_checker, :feature_actor, :global_id, :upload_store
|
|
41
44
|
|
|
42
45
|
def sdl_folder
|
|
43
46
|
@sdl_folder ||= (defined?(Rails) && Rails.respond_to?(:root) ? Rails.root.join('graphql').to_s : nil)
|
|
@@ -103,6 +106,15 @@ module Lighthouse
|
|
|
103
106
|
# when the schema is built, so set it in an initializer.
|
|
104
107
|
def convert_empty_strings_to_null = @convert_empty_strings_to_null || false
|
|
105
108
|
|
|
109
|
+
# Encodes and decodes Relay global ids (@globalId, @node): `encode(type, id)`
|
|
110
|
+
# and `decode(global_id)` -> [type, id]. Base64 of "Type:id" by default.
|
|
111
|
+
def global_id = @global_id || Lighthouse::GraphQL::GlobalId
|
|
112
|
+
|
|
113
|
+
# Stores the file an @upload argument carries: `call(file, disk:, path:,
|
|
114
|
+
# public:)` returns what the argument holds instead. An ActiveStorage blob's
|
|
115
|
+
# signed id by default.
|
|
116
|
+
def upload_store = @upload_store || Lighthouse::GraphQL::UploadStore
|
|
117
|
+
|
|
106
118
|
def persisted_queries_store
|
|
107
119
|
@persisted_queries_store || cache_store
|
|
108
120
|
end
|
|
@@ -19,7 +19,8 @@ require 'json'
|
|
|
19
19
|
#
|
|
20
20
|
# `execute` dispatches StartRequest, reads the variables (a JSON String, a Hash
|
|
21
21
|
# or parameters), runs the operation through PersistedQueries.execute (or
|
|
22
|
-
# PersistedQueries.multiplex for a batch, a JSON array body
|
|
22
|
+
# PersistedQueries.multiplex for a batch, a JSON array body; a multipart
|
|
23
|
+
# request's operations, with its files, as MultipartRequest reads them), sets the
|
|
23
24
|
# Cache-Control header the selected fields' @cacheControl hints allow, renders
|
|
24
25
|
# the JSON and dispatches EndRequest. In development an error renders as JSON.
|
|
25
26
|
#
|
|
@@ -46,11 +47,11 @@ module Lighthouse::GraphQL::Controller
|
|
|
46
47
|
|
|
47
48
|
def execute
|
|
48
49
|
lighthouse_request do
|
|
49
|
-
|
|
50
|
-
if
|
|
51
|
-
render json: lighthouse_batch(
|
|
50
|
+
operations = lighthouse_operations
|
|
51
|
+
if operations.is_a?(Array)
|
|
52
|
+
render json: lighthouse_batch(operations.map { |operation| lighthouse_hash(operation) })
|
|
52
53
|
else
|
|
53
|
-
result = lighthouse_operation
|
|
54
|
+
result = lighthouse_operation(operations)
|
|
54
55
|
Lighthouse::GraphQL::CacheControl.apply(response, result)
|
|
55
56
|
render json: result
|
|
56
57
|
end
|
|
@@ -80,6 +81,11 @@ module Lighthouse::GraphQL::Controller
|
|
|
80
81
|
respond_to?(:current_user, true) ? { current_user: current_user } : {}
|
|
81
82
|
end
|
|
82
83
|
|
|
84
|
+
# graphql_context, with the client's IP (for @throttle) unless it sets its own.
|
|
85
|
+
def lighthouse_context
|
|
86
|
+
{ remote_ip: request.remote_ip }.merge(graphql_context)
|
|
87
|
+
end
|
|
88
|
+
|
|
83
89
|
def graphql_schema
|
|
84
90
|
source = lighthouse_schema_source
|
|
85
91
|
case source
|
|
@@ -103,11 +109,23 @@ module Lighthouse::GraphQL::Controller
|
|
|
103
109
|
end
|
|
104
110
|
end
|
|
105
111
|
|
|
106
|
-
|
|
112
|
+
# A multipart request's operation(s), a batch (a JSON array body), or the parameters.
|
|
113
|
+
def lighthouse_operations
|
|
114
|
+
Lighthouse::GraphQL::MultipartRequest.operations(params) || params[:_json] || params
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Parameters, or a Hash from a multipart request's JSON, read by key either way.
|
|
118
|
+
def lighthouse_hash(operation)
|
|
119
|
+
(operation.respond_to?(:to_unsafe_h) ? operation.to_unsafe_h : operation.to_h).with_indifferent_access
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# `operation`: the request's parameters, or a multipart request's operation.
|
|
123
|
+
def lighthouse_operation(operation = params)
|
|
124
|
+
operation = operation.with_indifferent_access if operation.is_a?(Hash)
|
|
107
125
|
Lighthouse::GraphQL::PersistedQueries.execute(
|
|
108
126
|
graphql_schema,
|
|
109
|
-
query:
|
|
110
|
-
variables: graphql_variables(
|
|
127
|
+
query: operation[:query], extensions: operation[:extensions],
|
|
128
|
+
variables: graphql_variables(operation[:variables]), operation_name: operation[:operationName], context: lighthouse_context
|
|
111
129
|
)
|
|
112
130
|
end
|
|
113
131
|
|
|
@@ -116,7 +134,7 @@ module Lighthouse::GraphQL::Controller
|
|
|
116
134
|
def lighthouse_batch(operations)
|
|
117
135
|
operations = operations.map do |operation|
|
|
118
136
|
{ query: operation[:query], extensions: operation[:extensions], variables: graphql_variables(operation[:variables]),
|
|
119
|
-
operation_name: operation[:operationName], context:
|
|
137
|
+
operation_name: operation[:operationName], context: lighthouse_context }
|
|
120
138
|
end
|
|
121
139
|
Lighthouse::GraphQL::PersistedQueries.multiplex(graphql_schema, operations)
|
|
122
140
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Eager-loads associations into every record the Dataloader collected, of
|
|
4
|
+
# every parent, in one query per association: @with and @lazyLoad.
|
|
5
|
+
class Lighthouse::GraphQL::DataloaderSources::PreloadLoader < GraphQL::Dataloader::Source
|
|
6
|
+
def initialize(associations, scopes)
|
|
7
|
+
super()
|
|
8
|
+
@associations = associations
|
|
9
|
+
@scopes = scopes
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# keys: the records
|
|
13
|
+
def fetch(records)
|
|
14
|
+
Lighthouse::Support::Preload.records(records, @associations, @scopes)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# How many related records each parent has, for @withCount: every parent the
|
|
4
|
+
# Dataloader collected in one GROUP BY query when the relation can be batched
|
|
5
|
+
# (RelationBatch), else one COUNT per parent. Each count is kept on its parent
|
|
6
|
+
# as `<relation>_count` (RelationCounts).
|
|
7
|
+
class Lighthouse::GraphQL::DataloaderSources::RelationCountsLoader < GraphQL::Dataloader::Source
|
|
8
|
+
# Applies the directive's scopes to the related records, as RelationBatch
|
|
9
|
+
# applies a field's filters. A value, so equal scopes batch together.
|
|
10
|
+
Scopes = Struct.new(:names) do
|
|
11
|
+
def decorate_relation(relation, _args, _context)
|
|
12
|
+
Array(names).reduce(relation) { |scoped, scope| scoped.public_send(scope) }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(relation, scopes)
|
|
17
|
+
super()
|
|
18
|
+
@relation = relation.to_s
|
|
19
|
+
@scopes = Scopes.new(Array(scopes))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# keys: the parent records, of one class or several
|
|
23
|
+
def fetch(parents)
|
|
24
|
+
counts = {}.compare_by_identity
|
|
25
|
+
parents.group_by(&:class).each_value { |group| counts.merge!(counts_for(group)) }
|
|
26
|
+
parents.map do |parent|
|
|
27
|
+
Lighthouse::GraphQL::RelationCounts.write(parent, @relation, counts[parent])
|
|
28
|
+
counts[parent]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# { parent => count } for parents of one class.
|
|
35
|
+
def counts_for(parents)
|
|
36
|
+
batch = Lighthouse::GraphQL::RelationBatch.for(parents.first, @relation, {}, @scopes, nil)
|
|
37
|
+
return parents.index_with { |parent| own_count(parent) } unless batch
|
|
38
|
+
|
|
39
|
+
counts = batch.counts(parents)
|
|
40
|
+
parents.index_with { |parent| counts.fetch(batch.owner_key(parent), 0) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def own_count(parent)
|
|
44
|
+
@scopes.decorate_relation(parent.public_send(@relation), nil, nil).count
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The schema's default resolve while Lighthouse runs it. graphql-ruby asks that
|
|
4
|
+
# one object for every field, for the type of a union's or interface's value
|
|
5
|
+
# (`resolve_type`) and for scalar coercion: the dispatcher answers fields with
|
|
6
|
+
# our generated resolvers, and hands everything else to the implementation it
|
|
7
|
+
# replaced (SchemaImplementation).
|
|
8
|
+
class Lighthouse::GraphQL::DefaultResolveDispatcher
|
|
9
|
+
def initialize(schema, previous)
|
|
10
|
+
@schema = schema
|
|
11
|
+
@previous = previous
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(type, field, obj, arguments, ctx)
|
|
15
|
+
resolver = Lighthouse::GraphQL::GeneratedResolvers.generated_resolver(@schema, type, field)
|
|
16
|
+
return Lighthouse::GraphQL::GeneratedResolvers.invoke(resolver, obj, arguments, ctx) if resolver
|
|
17
|
+
|
|
18
|
+
@previous.call(type, field, obj, arguments, ctx)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def resolve_type(abstract_type, obj, ctx) = @previous.resolve_type(abstract_type, obj, ctx)
|
|
22
|
+
|
|
23
|
+
def coerce_input(type, value, ctx) = @previous.coerce_input(type, value, ctx)
|
|
24
|
+
|
|
25
|
+
def coerce_result(type, value, ctx) = @previous.coerce_result(type, value, ctx)
|
|
26
|
+
end
|
|
@@ -27,7 +27,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::AllDirectiveResolver < Lighthouse
|
|
|
27
27
|
|
|
28
28
|
relation = Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.apply(relation, @field_def.ast_node, args, ctx, self)
|
|
29
29
|
|
|
30
|
-
relation = apply_where_filters(relation, args)
|
|
30
|
+
relation = apply_where_filters(relation, args, ctx)
|
|
31
31
|
relation = relation.limit(args[:first]) if args[:first]
|
|
32
32
|
relation = relation.offset(args[:offset]) if args[:offset]
|
|
33
33
|
relation
|
|
@@ -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 (Config.upload_store), 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, and pass along what Config.upload_store
|
|
73
|
+
answers instead: by default, the signed id of an ActiveStorage blob, which an
|
|
74
|
+
attachment attribute (has_one_attached) attaches.
|
|
75
|
+
"""
|
|
76
|
+
directive @upload(
|
|
77
|
+
"The storage service to store the file on (an ActiveStorage service name). Defaults to the default one."
|
|
78
|
+
disk: String
|
|
79
|
+
"A prefix for the stored file's key."
|
|
80
|
+
path: String! = "/"
|
|
81
|
+
"Accepted for PHP Lighthouse parity: whether files are public is the service's setting."
|
|
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')
|
|
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::Config.upload_store.call(value, **options.compact)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def decode(value, mode)
|
|
173
|
+
return value.map { |item| decode(item, mode) } if value.is_a?(Array)
|
|
174
|
+
|
|
175
|
+
type, id = Lighthouse::GraphQL::GlobalId.decode!(value)
|
|
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
|