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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +182 -1
  3. data/README.md +18 -7
  4. data/docs/best-practices.md +16 -6
  5. data/docs/configuration.md +33 -8
  6. data/docs/directives.md +318 -10
  7. data/docs/events.md +3 -1
  8. data/docs/federation.md +3 -2
  9. data/docs/generators.md +82 -0
  10. data/docs/getting-started.md +66 -32
  11. data/docs/motivation.md +3 -2
  12. data/docs/relationships.md +42 -0
  13. data/docs/relay.md +137 -0
  14. data/docs/resolvers.md +338 -25
  15. data/docs/security.md +227 -0
  16. data/docs/subscriptions.md +60 -37
  17. data/docs/types.md +219 -0
  18. data/docs/uploads.md +80 -0
  19. data/lib/generators/lighthouse/base_classes.rb +70 -0
  20. data/lib/generators/lighthouse/class_generator.rb +19 -8
  21. data/lib/generators/lighthouse/install/install_generator.rb +96 -20
  22. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +19 -10
  23. data/lib/generators/lighthouse/install/templates/schema.rb.tt +21 -2
  24. data/lib/generators/lighthouse/mutation_generator.rb +5 -4
  25. data/lib/generators/lighthouse/query_generator.rb +5 -4
  26. data/lib/generators/lighthouse/subscription_generator.rb +21 -16
  27. data/lib/lighthouse/async_mutation_job.rb +17 -0
  28. data/lib/lighthouse/complexity.rb +175 -0
  29. data/lib/lighthouse/complexity_analyzer.rb +56 -0
  30. data/lib/lighthouse/config.rb +16 -4
  31. data/lib/lighthouse/controller.rb +27 -9
  32. data/lib/lighthouse/dataloader_sources/preload_loader.rb +16 -0
  33. data/lib/lighthouse/dataloader_sources/relation_counts_loader.rb +46 -0
  34. data/lib/lighthouse/default_resolve_dispatcher.rb +26 -0
  35. data/lib/lighthouse/directive_resolvers/all_resolver.rb +1 -1
  36. data/lib/lighthouse/directive_resolvers/argument_binding.rb +61 -0
  37. data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +220 -0
  38. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +2 -2
  39. data/lib/lighthouse/directive_resolvers/async_resolver.rb +90 -0
  40. data/lib/lighthouse/directive_resolvers/base.rb +63 -14
  41. data/lib/lighthouse/directive_resolvers/builder_applier.rb +89 -19
  42. data/lib/lighthouse/directive_resolvers/can_resolvers.rb +1 -1
  43. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +16 -5
  44. data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +1 -1
  45. data/lib/lighthouse/directive_resolvers/count_resolver.rb +1 -1
  46. data/lib/lighthouse/directive_resolvers/field_resolver.rb +35 -7
  47. data/lib/lighthouse/directive_resolvers/first_resolver.rb +1 -1
  48. data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +57 -0
  49. data/lib/lighthouse/directive_resolvers/lazy_load_resolver.rb +61 -0
  50. data/lib/lighthouse/directive_resolvers/limit_resolver.rb +59 -0
  51. data/lib/lighthouse/directive_resolvers/method_resolver.rb +38 -3
  52. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +1 -1
  53. data/lib/lighthouse/directive_resolvers/namespaced_resolver.rb +31 -0
  54. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +1 -1
  55. data/lib/lighthouse/directive_resolvers/resolution.rb +19 -4
  56. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +14 -5
  57. data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +77 -0
  58. data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +81 -0
  59. data/lib/lighthouse/directive_resolvers/with_count_resolver.rb +43 -0
  60. data/lib/lighthouse/directive_resolvers/with_resolver.rb +42 -0
  61. data/lib/lighthouse/directives/arguments/more_filters.rb +215 -0
  62. data/lib/lighthouse/directives/definitions.rb +52 -0
  63. data/lib/lighthouse/directives/model_directive.rb +131 -0
  64. data/lib/lighthouse/directives/node_directive.rb +134 -0
  65. data/lib/lighthouse/directives/pagination_manipulator.rb +1 -1
  66. data/lib/lighthouse/directives/registry.rb +21 -20
  67. data/lib/lighthouse/directives/visibility_directive.rb +71 -0
  68. data/lib/lighthouse/enum_directive.rb +41 -0
  69. data/lib/lighthouse/events/execution_trace.rb +2 -1
  70. data/lib/lighthouse/events/lifecycle.rb +3 -1
  71. data/lib/lighthouse/field_resolver_stack.rb +59 -11
  72. data/lib/lighthouse/generated_resolvers.rb +13 -8
  73. data/lib/lighthouse/global_id.rb +31 -0
  74. data/lib/lighthouse/graphql/version.rb +1 -1
  75. data/lib/lighthouse/multipart_request.rb +43 -0
  76. data/lib/lighthouse/node_registry.rb +68 -0
  77. data/lib/lighthouse/pagination.rb +3 -0
  78. data/lib/lighthouse/rb_lighthouse.rb +36 -0
  79. data/lib/lighthouse/relation_counts.rb +29 -0
  80. data/lib/lighthouse/resolve_info.rb +80 -0
  81. data/lib/lighthouse/resolver_bases.rb +68 -0
  82. data/lib/lighthouse/resolver_dispatch.rb +36 -11
  83. data/lib/lighthouse/scalar_coercion.rb +72 -0
  84. data/lib/lighthouse/schema_cache.rb +2 -1
  85. data/lib/lighthouse/schema_factory.rb +0 -1
  86. data/lib/lighthouse/schema_generator.rb +21 -12
  87. data/lib/lighthouse/schema_implementation.rb +4 -8
  88. data/lib/lighthouse/sdl_loader.rb +0 -2
  89. data/lib/lighthouse/support/environment.rb +13 -0
  90. data/lib/lighthouse/support/model_resolver.rb +8 -6
  91. data/lib/lighthouse/support/preload.rb +49 -0
  92. data/lib/lighthouse/type.rb +103 -0
  93. data/lib/lighthouse/type_resolution.rb +150 -0
  94. data/lib/lighthouse/upload.rb +26 -0
  95. data/lib/lighthouse/upload_store.rb +32 -0
  96. data/sig/lighthouse/directives.rbs +3 -2
  97. data/sig/lighthouse/graphql.rbs +29 -0
  98. data/sig/lighthouse.rbs +18 -0
  99. metadata +43 -1
@@ -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/queries, .../mutations, ...).
15
+ # contracts, so distinct namespaces (app/graphql/resolvers, .../mutations, ...).
16
16
  DEFAULT_NAMESPACES = {
17
- queries: ['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), sets the
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
- batch = params[:_json]
50
- if batch.is_a?(Array)
51
- render json: lighthouse_batch(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
- def lighthouse_operation
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: params[:query], extensions: params[:extensions],
110
- variables: graphql_variables(params[:variables]), operation_name: params[:operationName], context: graphql_context
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: graphql_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, _ctx)
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