lighthouse-graphql 0.2.0 → 0.3.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/README.md +16 -1
  4. data/docs/caching.md +1 -1
  5. data/docs/configuration.md +68 -0
  6. data/docs/custom-directives.md +11 -1
  7. data/docs/directives.md +306 -0
  8. data/docs/events.md +201 -0
  9. data/docs/getting-started.md +38 -9
  10. data/docs/motivation.md +184 -0
  11. data/docs/mutations.md +15 -0
  12. data/docs/resolvers.md +13 -1
  13. data/docs/subscriptions.md +423 -0
  14. data/lib/generators/lighthouse/builder_generator.rb +1 -1
  15. data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +7 -45
  16. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +31 -3
  17. data/lib/generators/lighthouse/subscription_generator.rb +95 -0
  18. data/lib/lighthouse/broadcast_job.rb +14 -0
  19. data/lib/lighthouse/broadcasting.rb +143 -0
  20. data/lib/lighthouse/cache_control.rb +5 -2
  21. data/lib/lighthouse/config.rb +53 -12
  22. data/lib/lighthouse/controller.rb +131 -0
  23. data/lib/lighthouse/directive_resolvers/broadcast_resolver.rb +31 -0
  24. data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +134 -0
  25. data/lib/lighthouse/directive_resolvers/event_resolver.rb +37 -0
  26. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +28 -13
  27. data/lib/lighthouse/directive_resolvers/soft_delete_resolvers.rb +66 -0
  28. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +68 -0
  29. data/lib/lighthouse/directives/arguments/soft_deletes.rb +112 -0
  30. data/lib/lighthouse/directives/registry.rb +14 -6
  31. data/lib/lighthouse/directives/soft_deletes_directive.rb +123 -0
  32. data/lib/lighthouse/events/execution_trace.rb +51 -0
  33. data/lib/lighthouse/events/lifecycle.rb +146 -0
  34. data/lib/lighthouse/events/schema_build.rb +44 -0
  35. data/lib/lighthouse/events.rb +76 -0
  36. data/lib/lighthouse/feature_directive.rb +137 -0
  37. data/lib/lighthouse/field_resolver_stack.rb +22 -2
  38. data/lib/lighthouse/graphql/version.rb +1 -1
  39. data/lib/lighthouse/namespaces.rb +1 -0
  40. data/lib/lighthouse/persisted_queries/batch.rb +34 -0
  41. data/lib/lighthouse/persisted_queries.rb +17 -4
  42. data/lib/lighthouse/rb_lighthouse.rb +20 -0
  43. data/lib/lighthouse/schema_cache.rb +12 -5
  44. data/lib/lighthouse/schema_generator.rb +27 -2
  45. data/lib/lighthouse/subscription.rb +120 -0
  46. data/lib/lighthouse/support/soft_deletes.rb +164 -0
  47. data/lib/lighthouse/tasks/lighthouse.rake +10 -0
  48. data/sig/lighthouse/controller.rbs +30 -0
  49. data/sig/lighthouse/directives.rbs +158 -0
  50. data/sig/lighthouse/events.rbs +122 -0
  51. data/sig/lighthouse/graphql.rbs +111 -0
  52. data/sig/lighthouse.rbs +193 -0
  53. metadata +29 -2
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ # What @broadcast does once a mutation has resolved: trigger the subscription
4
+ # field on the schema's subscriptions backend (`schema.subscriptions.trigger`),
5
+ # inline or, when queued, in a Lighthouse::GraphQL::BroadcastJob. Also checks,
6
+ # when the schema is built, that every @broadcast names a Subscription field.
7
+ module Lighthouse::GraphQL::Broadcasting
8
+ module_function
9
+
10
+ # Broadcast `root` to the subscribers of the field `name`.
11
+ def broadcast(schema, name, root, context, should_queue: nil)
12
+ return unless backend?(schema, name)
13
+
14
+ klass = subscription_class(schema, name)
15
+ return unless triggerable?(schema, name, klass)
16
+
17
+ arguments = klass.respond_to?(:trigger_arguments) ? klass.trigger_arguments(root, context) : {}
18
+ scope = klass.respond_to?(:trigger_scope) ? klass.trigger_scope(root, context) : nil
19
+
20
+ if should_queue.nil? ? Lighthouse::Config.queue_broadcasts : should_queue
21
+ enqueue(schema, name, arguments, root, scope)
22
+ else
23
+ trigger(schema, name, arguments, root, scope)
24
+ end
25
+ end
26
+
27
+ # Can a trigger of `klass` reach anybody? graphql-ruby delivers to the
28
+ # subscribers whose arguments equal the trigger's. A class with exact-argument
29
+ # topics (a plain GraphQL::Schema::Subscription, or topic_by :arguments) that
30
+ # leaves `trigger_arguments` at the default `{}`, on a field with a required
31
+ # argument, therefore never reaches a single subscriber: each of them passed
32
+ # that argument. That is certain, so it is reported through Config.on_error
33
+ # (raised in a test environment, logged otherwise) and nothing is triggered.
34
+ # Arguments that are all optional are not reported: argument-less subscribers
35
+ # do match `{}`, which can be exactly what the class wants.
36
+ def triggerable?(schema, name, klass)
37
+ field = subscription_field(schema, name)
38
+ required = field ? required_arguments(field) : []
39
+ return true if required.empty? || trigger_arguments_defined?(klass)
40
+
41
+ Lighthouse::Config.handle_error(
42
+ Lighthouse::DefinitionError.new(
43
+ "@broadcast(subscription: #{name.inspect}) can reach nobody: #{klass} matches subscribers by exact arguments, " \
44
+ "but #{field.owner.graphql_name}.#{field.name} requires #{required.map { |arg| arg.graphql_name.inspect }.join(', ')} " \
45
+ 'and the class defines no `self.trigger_arguments(root, context)` saying what to trigger with. ' \
46
+ "Define it, e.g. `def self.trigger_arguments(root, _context) = { #{required.first.graphql_name.underscore}: root.id.to_s }`, " \
47
+ 'or include Lighthouse::GraphQL::Subscription for one topic per field, filtered by `filter?`.'
48
+ )
49
+ )
50
+ false
51
+ end
52
+
53
+ # Does a trigger of `klass` need its own trigger_arguments to match anyone?
54
+ # Not for the field-wide topic of a class including Lighthouse::GraphQL::Subscription.
55
+ def trigger_arguments_defined?(klass)
56
+ return true if klass < Lighthouse::GraphQL::Subscription && klass.topic_by == :field
57
+
58
+ klass.respond_to?(:trigger_arguments) && klass.method(:trigger_arguments).owner != Lighthouse::GraphQL::Subscription::ClassMethods
59
+ end
60
+
61
+ def required_arguments(field)
62
+ field.arguments.values.select { |arg| arg.type.non_null? && !arg.default_value? }
63
+ end
64
+
65
+ # Trigger the subscription now. Misuse (arguments the field does not take, a
66
+ # missing scope) goes through Config.on_error.
67
+ def trigger(schema, name, arguments, root, scope = nil)
68
+ schema.subscriptions.trigger(name, arguments, root, scope: scope)
69
+ rescue ::GraphQL::Subscriptions::InvalidTriggerError, ::GraphQL::Subscriptions::SubscriptionScopeMissingError => e
70
+ Lighthouse::Config.handle_error(e)
71
+ end
72
+
73
+ def enqueue(schema, name, arguments, root, scope)
74
+ if schema.name.nil?
75
+ raise Lighthouse::DefinitionError,
76
+ 'A queued @broadcast reaches its schema by name, but this schema is anonymous: assign it to a constant ' \
77
+ '(AppSchema = Lighthouse::GraphQL::RbLightHouse.get_schema).'
78
+ end
79
+
80
+ job = broadcast_job
81
+ queue = Lighthouse::Config.broadcast_queue
82
+ (queue ? job.set(queue: queue) : job).perform_later(schema.name, name, arguments, root, scope)
83
+ end
84
+
85
+ def broadcast_job
86
+ Lighthouse::GraphQL::BroadcastJob
87
+ rescue LoadError
88
+ raise Lighthouse::DefinitionError, 'Queueing @broadcast needs ActiveJob: add the activejob gem, or set shouldQueue: false.'
89
+ end
90
+
91
+ # Is there a backend to trigger? Reported through Config.on_error when not.
92
+ def backend?(schema, name)
93
+ return true if schema.subscriptions
94
+
95
+ Lighthouse::Config.handle_error(
96
+ Lighthouse::DefinitionError.new(
97
+ "@broadcast(subscription: #{name.inspect}) has nowhere to broadcast: #{schema.name || 'the schema'} has no subscriptions backend. " \
98
+ 'Install one, e.g. `use GraphQL::Subscriptions::ActionCableSubscriptions`, or pass ' \
99
+ '`options: { using: { GraphQL::Subscriptions::ActionCableSubscriptions => {} } }` to get_schema.'
100
+ )
101
+ )
102
+ false
103
+ end
104
+
105
+ # The Subscription field called `name` (as written in the SDL or in
106
+ # snake_case), or nil.
107
+ def subscription_field(schema, name)
108
+ fields = schema.subscription&.fields || {}
109
+ fields[name.to_s] || fields[name.to_s.camelize(:lower)]
110
+ end
111
+
112
+ # The class the field stands for (see SubscriptionDirectiveResolver).
113
+ def subscription_class(schema, name)
114
+ subscription_field(schema, name)&.resolver || Lighthouse::GraphQL::Subscription::Default
115
+ end
116
+
117
+ # Every @broadcast in the schema must name a field of its Subscription type.
118
+ def validate!(schema)
119
+ schema.types.each_value do |type|
120
+ next unless type.kind.object?
121
+
122
+ type.fields.each_value do |field_def|
123
+ broadcasts(field_def).each do |directive|
124
+ name = Lighthouse::Support::DirectiveArgs.literal(directive, 'subscription')
125
+ next if subscription_field(schema, name)
126
+
127
+ raise Lighthouse::DefinitionError, unknown_subscription(schema, type, field_def, name)
128
+ end
129
+ end
130
+ end
131
+ end
132
+
133
+ def broadcasts(field_def)
134
+ (field_def.ast_node&.directives || []).select { |directive| directive.name == 'broadcast' }
135
+ end
136
+
137
+ def unknown_subscription(schema, type, field_def, name)
138
+ where = "@broadcast on #{type.graphql_name}.#{field_def.name}"
139
+ return "#{where}: the schema has no Subscription type, so there is no subscription #{name.inspect}." unless schema.subscription
140
+
141
+ "#{where}: #{schema.subscription.graphql_name} has no field #{name.inspect}."
142
+ end
143
+ end
@@ -18,8 +18,11 @@
18
18
  # read the result with CacheControl.header(result) and set it on the
19
19
  # response (see `apply`).
20
20
  module Lighthouse::GraphQL::CacheControl
21
- DEFINITION = 'directive @cacheControl(maxAge: Int! = 0, scope: CacheControlScope! = PUBLIC, ' \
22
- 'inheritMaxAge: Boolean! = false) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION'
21
+ # Arguments with a default are nullable, not `Type! = default` as in PHP: graphql-ruby
22
+ # 2.5 does not fill in a directive argument's default, so a non-null one would be
23
+ # refused wherever it is left out. Left out or null, it takes its default.
24
+ DEFINITION = 'directive @cacheControl(maxAge: Int = 0, scope: CacheControlScope = PUBLIC, ' \
25
+ 'inheritMaxAge: Boolean = false) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION'
23
26
 
24
27
  TYPE_DEFINITIONS = {
25
28
  'CacheControlScope' => <<~GRAPHQL.chomp
@@ -17,7 +17,9 @@ module Lighthouse
17
17
  queries: ['Queries'],
18
18
  mutations: ['Mutations'],
19
19
  builders: ['Builders'],
20
- validators: ['Validators']
20
+ validators: ['Validators'],
21
+ events: ['Events'],
22
+ subscriptions: ['Subscriptions']
21
23
  }.freeze
22
24
 
23
25
  # Every setting reset! forgets, so the next read falls back to its default.
@@ -25,19 +27,23 @@ module Lighthouse
25
27
  sdl_folder resolver_namespaces namespaces cache_store transactional_mutations pagination_default_count pagination_max_count
26
28
  schema_cache schema_cache_path watch_sdl persisted_queries persisted_queries_store persisted_queries_ttl
27
29
  persisted_queries_manifest persisted_queries_only on_error directive_map authenticated_user policy_user authorizer
30
+ feature_checker feature_actor
31
+ convert_empty_strings_to_null
32
+ soft_deletes
33
+ queue_broadcasts broadcast_queue
28
34
  ].freeze
29
35
 
30
36
  class << self
31
37
  attr_writer :sdl_folder, :resolver_namespaces, :on_error, :authenticated_user, :policy_user, :authorizer, :cache_store,
32
- :transactional_mutations, :schema_cache, :watch_sdl, :persisted_queries, :persisted_queries_store, :persisted_queries_only
38
+ :transactional_mutations, :schema_cache, :watch_sdl, :persisted_queries, :persisted_queries_store, :persisted_queries_only,
39
+ :queue_broadcasts,
40
+ :convert_empty_strings_to_null, :feature_checker, :feature_actor
33
41
 
34
42
  def sdl_folder
35
43
  @sdl_folder ||= (defined?(Rails) && Rails.respond_to?(:root) ? Rails.root.join('graphql').to_s : nil)
36
44
  end
37
45
 
38
- def directive_map
39
- @directive_map ||= {}
40
- end
46
+ def directive_map = @directive_map ||= {}
41
47
 
42
48
  # Namespaces tried (in order) when resolving a class name from a directive
43
49
  # argument, e.g. `@field(resolver: "ContactPerson")` or `@all(model: "X")`.
@@ -67,6 +73,15 @@ module Lighthouse
67
73
  end
68
74
  end
69
75
 
76
+ # --- Subscriptions (used by @broadcast) ----------------------------------
77
+ #
78
+ # queue_broadcasts perform @broadcast triggers in a Lighthouse::GraphQL::BroadcastJob
79
+ # (ActiveJob) instead of inline; a directive's shouldQueue overrides it
80
+ # broadcast_queue the job's queue name; nil: ActiveJob's default
81
+ attr_accessor :broadcast_queue
82
+
83
+ def queue_broadcasts = @queue_broadcasts || false
84
+
70
85
  # --- Scale options: all off by default ----------------------------------
71
86
  #
72
87
  # schema_cache cache the built schema document in tmp/cache (SchemaCache)
@@ -84,6 +99,10 @@ module Lighthouse
84
99
  def persisted_queries = @persisted_queries || false
85
100
  def persisted_queries_only = @persisted_queries_only || false
86
101
 
102
+ # Make every field behave as if it carried @convertEmptyStringsToNull. Read
103
+ # when the schema is built, so set it in an initializer.
104
+ def convert_empty_strings_to_null = @convert_empty_strings_to_null || false
105
+
87
106
  def persisted_queries_store
88
107
  @persisted_queries_store || cache_store
89
108
  end
@@ -102,7 +121,7 @@ module Lighthouse
102
121
  # re-raising in test (so failures surface) and logging otherwise. Override
103
122
  # via `Lighthouse.configure { |c| c.on_error = ->(e, ctx) { ... } }`.
104
123
  def on_error
105
- @on_error ||= default_on_error
124
+ @on_error ||= Defaults.on_error
106
125
  end
107
126
 
108
127
  def handle_error(error, context = nil)
@@ -131,9 +150,28 @@ module Lighthouse
131
150
  # `record` may be a model class (@canModel). `args` carries the @can*
132
151
  # `args:` values and, with `injectArgs: true`, the field's arguments first.
133
152
  # Defaults to Pundit: `Pundit.policy!(user, record).<ability>?(*args)`.
134
- def authorizer
135
- @authorizer ||= default_authorizer
136
- end
153
+ def authorizer = @authorizer ||= Defaults.authorizer
154
+
155
+ # --- Feature flags (used by @feature) ------------------------------------
156
+
157
+ # Is a feature on? `->(name, actor, ctx) { true/false }`, with the feature's
158
+ # name as written in the SDL. Defaults to Flipper: `Flipper.enabled?(name.to_sym, actor)`.
159
+ def feature_checker = @feature_checker ||= Lighthouse::GraphQL::FeatureDirective.default_checker
160
+
161
+ # The actor features are checked for: `->(ctx) { actor }` (a Flipper actor
162
+ # answers `flipper_id`; nil checks the feature globally). Defaults to the
163
+ # authenticated user.
164
+ def feature_actor = @feature_actor ||= ->(ctx) { authenticated_user.call(ctx) }
165
+
166
+ # --- Soft deletes (used by @softDeletes, @trashed, @restore, @forceDelete) ---
167
+
168
+ # Teaches Lighthouse what "trashed" means. Any object answering
169
+ # `soft_deletes?(model)`, `only_trashed(relation)`, `with_trashed(relation)`,
170
+ # `without_trashed(relation)`, `restore(record)` and `force_delete(record)`.
171
+ # Defaults to detecting paranoia, then discard, then a `deleted_at` column
172
+ # per model (Support::SoftDeletes::Auto); `Support::SoftDeletes::Column.new(:archived_at)`
173
+ # is one for another column.
174
+ attr_accessor :soft_deletes
137
175
 
138
176
  def configure
139
177
  yield(self) if block_given?
@@ -146,10 +184,13 @@ module Lighthouse
146
184
  def reset!
147
185
  SETTINGS.each { |setting| instance_variable_set("@#{setting}", nil) }
148
186
  end
187
+ end
149
188
 
150
- private
189
+ # The callables the authorizer and on_error settings fall back to.
190
+ module Defaults
191
+ module_function
151
192
 
152
- def default_authorizer
193
+ def authorizer
153
194
  lambda do |user, record, ability, args = []|
154
195
  raise 'Lighthouse: configure `Config.authorizer` to use the @can* directives (Pundit not detected).' unless defined?(::Pundit)
155
196
 
@@ -158,7 +199,7 @@ module Lighthouse
158
199
  end
159
200
  end
160
201
 
161
- def default_on_error
202
+ def on_error
162
203
  lambda do |error, _context|
163
204
  if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.test?
164
205
  raise error
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+ require 'active_support/core_ext/class/attribute'
5
+ require 'json'
6
+
7
+ # A Rails controller concern that serves a Lighthouse schema: include it, name
8
+ # the schema, route to `execute`.
9
+ #
10
+ # class GraphqlController < ApplicationController
11
+ # include Lighthouse::GraphQL::Controller
12
+ #
13
+ # lighthouse_schema AppSchema # or 'AppSchema', or { AppSchema }, resolved per request
14
+ #
15
+ # private
16
+ #
17
+ # def graphql_context = { current_user: current_user }
18
+ # end
19
+ #
20
+ # `execute` dispatches StartRequest, reads the variables (a JSON String, a Hash
21
+ # or parameters), runs the operation through PersistedQueries.execute (or
22
+ # PersistedQueries.multiplex for a batch, a JSON array body), sets the
23
+ # Cache-Control header the selected fields' @cacheControl hints allow, renders
24
+ # the JSON and dispatches EndRequest. In development an error renders as JSON.
25
+ #
26
+ # Hooks, private so they are not actions: `graphql_context` (default:
27
+ # `{ current_user: current_user }` when the controller has a current_user, else
28
+ # `{}`), `graphql_schema` and `graphql_variables(param)`. A controller with an
29
+ # action of its own wraps it in `lighthouse_request { ... }` to get the request
30
+ # events; a nested call dispatches them once.
31
+ module Lighthouse::GraphQL::Controller
32
+ extend ActiveSupport::Concern
33
+
34
+ included do
35
+ class_attribute :lighthouse_schema_source, instance_writer: false
36
+ end
37
+
38
+ class_methods do
39
+ # The schema `execute` runs: a schema class, its name (a String, constantized
40
+ # per request so code reloading works) or a block (evaluated in the
41
+ # controller, per request).
42
+ def lighthouse_schema(schema = nil, &block)
43
+ self.lighthouse_schema_source = schema || block
44
+ end
45
+ end
46
+
47
+ def execute
48
+ lighthouse_request do
49
+ batch = params[:_json]
50
+ if batch.is_a?(Array)
51
+ render json: lighthouse_batch(batch)
52
+ else
53
+ result = lighthouse_operation
54
+ Lighthouse::GraphQL::CacheControl.apply(response, result)
55
+ render json: result
56
+ end
57
+ end
58
+ rescue StandardError => e
59
+ raise e unless defined?(::Rails) && ::Rails.env.development?
60
+
61
+ lighthouse_development_error(e)
62
+ end
63
+
64
+ private
65
+
66
+ # Dispatch StartRequest, run the block, dispatch EndRequest with the
67
+ # response. Only the outermost call dispatches.
68
+ def lighthouse_request
69
+ return yield if @lighthouse_request_started
70
+
71
+ @lighthouse_request_started = true
72
+ Lighthouse::Events.dispatch(Lighthouse::Events::StartRequest.new(request))
73
+ result = yield
74
+ Lighthouse::Events.dispatch(Lighthouse::Events::EndRequest.new(response))
75
+ result
76
+ end
77
+
78
+ # The context of the operation: what @guard and the @can* directives read.
79
+ def graphql_context
80
+ respond_to?(:current_user, true) ? { current_user: current_user } : {}
81
+ end
82
+
83
+ def graphql_schema
84
+ source = lighthouse_schema_source
85
+ case source
86
+ when nil then raise Lighthouse::DefinitionError, "#{self.class}: name the schema with `lighthouse_schema AppSchema`"
87
+ when String, Symbol then source.to_s.constantize
88
+ when Proc then instance_exec(&source)
89
+ else source
90
+ end
91
+ end
92
+
93
+ # Variables arrive as a JSON string (GET or form posts) or as parameters.
94
+ def graphql_variables(param)
95
+ case param
96
+ when String then param.present? ? JSON.parse(param) || {} : {}
97
+ when Hash then param
98
+ when nil then {}
99
+ else
100
+ raise ArgumentError, "Unexpected parameter: #{param}" unless param.respond_to?(:to_unsafe_h)
101
+
102
+ param.to_unsafe_h
103
+ end
104
+ end
105
+
106
+ def lighthouse_operation
107
+ Lighthouse::GraphQL::PersistedQueries.execute(
108
+ graphql_schema,
109
+ query: params[:query], extensions: params[:extensions],
110
+ variables: graphql_variables(params[:variables]), operation_name: params[:operationName], context: graphql_context
111
+ )
112
+ end
113
+
114
+ # Each operation gets its own context: `schema.multiplex`'s `context:` is the
115
+ # multiplex's, which the queries' resolvers never see.
116
+ def lighthouse_batch(operations)
117
+ operations = operations.map do |operation|
118
+ { query: operation[:query], extensions: operation[:extensions], variables: graphql_variables(operation[:variables]),
119
+ operation_name: operation[:operationName], context: graphql_context }
120
+ end
121
+ Lighthouse::GraphQL::PersistedQueries.multiplex(graphql_schema, operations)
122
+ end
123
+
124
+ def lighthouse_development_error(error)
125
+ logger.error(error.message)
126
+ logger.error(error.backtrace.join("\n"))
127
+
128
+ render json: { errors: [{ message: error.message, backtrace: error.backtrace }], data: {} },
129
+ status: :internal_server_error
130
+ end
131
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @broadcast — on a mutation (or any field), trigger a subscription once the
6
+ # field resolved successfully: its result is the root the subscribers'
7
+ # updates are resolved from, each passing through the subscription's
8
+ # `filter?`. A nil result or a GraphQL::ExecutionError (a failed mutation)
9
+ # broadcasts nothing; repeat the directive to trigger several subscriptions.
10
+ # See Lighthouse::GraphQL::Broadcasting for how it is triggered, and queued.
11
+ class Lighthouse::GraphQL::DirectiveResolvers::BroadcastDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
12
+ def self.directive_name = 'broadcast'
13
+
14
+ def self.definition
15
+ 'directive @broadcast(subscription: String!, shouldQueue: Boolean) repeatable on FIELD_DEFINITION'
16
+ end
17
+
18
+ def define_resolver(target_type_class)
19
+ subscription = directive_arg('subscription')
20
+ should_queue = directive_arg('shouldQueue')
21
+ inner = capture_inner(target_type_class)
22
+
23
+ install(target_type_class, lambda do |obj, args, ctx|
24
+ result = inner.call(obj, args, ctx)
25
+ unless result.nil? || result.is_a?(::GraphQL::ExecutionError)
26
+ Lighthouse::GraphQL::Broadcasting.broadcast(ctx.schema, subscription, result, ctx, should_queue: should_queue)
27
+ end
28
+ result
29
+ end)
30
+ end
31
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @convertEmptyStringsToNull — replace incoming empty strings with null, as
6
+ # PHP Lighthouse's directive of the same name does.
7
+ #
8
+ # type Mutation {
9
+ # createPost(title: String @convertEmptyStringsToNull): Post! # this argument
10
+ # createUser(input: UserInput!): User! @convertEmptyStringsToNull # every input of the field
11
+ # }
12
+ # input UserInput { name: String @convertEmptyStringsToNull, bio: String }
13
+ #
14
+ # On a field it applies to all the field's inputs, nested input objects and
15
+ # lists included, but leaves a non-null input (`String!`) alone: only placing
16
+ # the directive on that argument or input field itself forces the conversion.
17
+ # On an input field it applies wherever the input type is used. Setting
18
+ # Config.convert_empty_strings_to_null makes every field behave as if it
19
+ # carried the directive.
20
+ #
21
+ # The conversion is a wrapper around the field's resolver (outermost, order
22
+ # 6), so every kind of resolver — directives, @field, Queries::/Mutations::
23
+ # classes, the default reader — and @validator see the converted arguments,
24
+ # and other fields sharing an input type are unaffected. SchemaGenerator
25
+ # installs it too, without the directive on the field, when an argument or
26
+ # input type it takes carries the directive, or the setting is on.
27
+ class Lighthouse::GraphQL::DirectiveResolvers::ConvertEmptyStringsToNullDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
28
+ NAME = 'convertEmptyStringsToNull'
29
+
30
+ def self.directive_name = NAME
31
+
32
+ def self.definition
33
+ <<~GRAPHQL.chomp
34
+ """
35
+ Replaces incoming empty strings `""` with `null`.
36
+
37
+ When used upon fields, empty strings for non-nullable inputs will pass unchanged.
38
+ Only explicitly placing this on non-nullable inputs will force the conversion.
39
+ """
40
+ directive @convertEmptyStringsToNull on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | FIELD_DEFINITION
41
+ GRAPHQL
42
+ end
43
+
44
+ # Does a field without the directive still convert? Yes when the setting is
45
+ # on, or when an argument or input type it takes carries the directive.
46
+ def self.implicit?(field_def)
47
+ return false if field_def.arguments.empty?
48
+
49
+ Lighthouse::Config.convert_empty_strings_to_null || carried?(field_def.arguments)
50
+ end
51
+
52
+ def self.explicit?(argument)
53
+ (argument.ast_node&.directives || []).any? { |d| d.name == NAME }
54
+ end
55
+
56
+ # Does any argument, at any depth of input types, carry the directive?
57
+ def self.carried?(argument_defs, seen = Set.new)
58
+ argument_defs.each_value.any? do |argument|
59
+ type = argument.type.unwrap
60
+ explicit?(argument) || (type.kind.input_object? && seen.add?(type.graphql_name) && carried?(type.arguments, seen))
61
+ end
62
+ end
63
+
64
+ def initialize(field_def, type_def, directive_node, opts = {})
65
+ super
66
+ @active = !directive_node.nil? || Lighthouse::Config.convert_empty_strings_to_null
67
+ end
68
+
69
+ def define_resolver(target_type_class)
70
+ me = self
71
+ inner = capture_inner(target_type_class)
72
+
73
+ install(target_type_class, lambda do |obj, args, ctx|
74
+ inner.call(obj, me.convert_arguments(args), ctx)
75
+ end)
76
+ end
77
+
78
+ # The field's arguments (a Hash keyed by snake_case symbols) with the empty
79
+ # strings replaced. Input objects that change are copied, never mutated.
80
+ def convert_arguments(args)
81
+ return args unless args.is_a?(Hash)
82
+
83
+ convert_hash(@field_def.arguments, args, @active)
84
+ end
85
+
86
+ private
87
+
88
+ def convert_hash(argument_defs, values, active)
89
+ by_keyword = Lighthouse::Support::Arguments.keyword_index(argument_defs)
90
+ values.to_h { |key, value| [key, convert_argument(by_keyword[key.to_sym], value, active)] }
91
+ end
92
+
93
+ def convert_argument(argument, value, active)
94
+ return value if argument.nil? || value.nil?
95
+
96
+ explicit = self.class.explicit?(argument)
97
+ convert_value(argument.type, value, active || explicit, explicit)
98
+ end
99
+
100
+ # `active`: the enclosing field or argument converts its nullable inputs.
101
+ # `force`: this argument itself carries the directive, so non-null ones too.
102
+ def convert_value(type, value, active, force)
103
+ nullable = !type.kind.non_null?
104
+ type = type.of_type unless nullable
105
+ case type.kind.name
106
+ when 'LIST' then convert_list(type.of_type, value, active, force)
107
+ when 'INPUT_OBJECT' then convert_input(type, value, active)
108
+ when 'SCALAR' then convert_scalar(value, force || (active && nullable))
109
+ else value
110
+ end
111
+ end
112
+
113
+ def convert_scalar(value, convert)
114
+ convert && value == '' ? nil : value
115
+ end
116
+
117
+ def convert_list(item_type, value, active, force)
118
+ return value unless value.is_a?(Array)
119
+
120
+ items = value.map { |item| convert_value(item_type, item, active, force) }
121
+ items.zip(value).all? { |new, old| new.equal?(old) } ? value : items
122
+ end
123
+
124
+ def convert_input(type, value, active)
125
+ values = value.respond_to?(:to_kwargs) ? value.to_kwargs : value
126
+ return value unless values.is_a?(Hash)
127
+
128
+ converted = convert_hash(type.arguments, values, active)
129
+ return value if converted.all? { |key, new| new.equal?(values[key]) }
130
+ return converted unless value.respond_to?(:to_kwargs)
131
+
132
+ value.dup.tap { |copy| copy.instance_variable_set(:@ruby_style_hash, converted) }
133
+ end
134
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @event(dispatch: "OrderShipped") — dispatch an event once the field has
6
+ # resolved: the class is built with the resolved value and handed to
7
+ # Lighthouse::Events.dispatch. The value is returned unchanged; if the field
8
+ # raises, nothing is dispatched. Repeatable: each @event dispatches its own.
9
+ #
10
+ # The class is looked up in Config.namespaces[:events] (a fully-qualified
11
+ # name is used as-is); a name that is no class fails the schema build.
12
+ class Lighthouse::GraphQL::DirectiveResolvers::EventDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
13
+ def self.directive_name = 'event'
14
+ def self.definition = 'directive @event(dispatch: String!) repeatable on FIELD_DEFINITION'
15
+
16
+ def define_resolver(target_type_class)
17
+ name = directive_arg('dispatch')
18
+ self.class.event_class(name, "#{@type_def.graphql_name}.#{@field_def.name}")
19
+ inner = capture_inner(target_type_class)
20
+
21
+ install(target_type_class, lambda do |obj, args, ctx|
22
+ result = inner.call(obj, args, ctx)
23
+ Lighthouse::Events.dispatch(Lighthouse::GraphQL::DirectiveResolvers::EventDirectiveResolver.event_class(name).new(result))
24
+ result
25
+ end)
26
+ end
27
+
28
+ # The event class `name` names. Looked up on every dispatch, so a reloaded
29
+ # class is the one built.
30
+ def self.event_class(name, location = nil)
31
+ klass = Lighthouse::Support::ModelResolver.constantize(name, namespaces: Lighthouse::Config.namespaces[:events])
32
+ return klass if klass.respond_to?(:new)
33
+
34
+ raise Lighthouse::DefinitionError,
35
+ "@event on #{location}: no event class #{name} (looked in #{Lighthouse::Config.namespaces[:events].join(', ')})"
36
+ end
37
+ end