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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -1
- data/README.md +16 -1
- data/docs/caching.md +1 -1
- data/docs/configuration.md +68 -0
- data/docs/custom-directives.md +11 -1
- data/docs/directives.md +306 -0
- data/docs/events.md +201 -0
- data/docs/getting-started.md +38 -9
- data/docs/motivation.md +184 -0
- data/docs/mutations.md +15 -0
- data/docs/resolvers.md +13 -1
- data/docs/subscriptions.md +423 -0
- data/lib/generators/lighthouse/builder_generator.rb +1 -1
- data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +7 -45
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +31 -3
- data/lib/generators/lighthouse/subscription_generator.rb +95 -0
- data/lib/lighthouse/broadcast_job.rb +14 -0
- data/lib/lighthouse/broadcasting.rb +143 -0
- data/lib/lighthouse/cache_control.rb +5 -2
- data/lib/lighthouse/config.rb +53 -12
- data/lib/lighthouse/controller.rb +131 -0
- data/lib/lighthouse/directive_resolvers/broadcast_resolver.rb +31 -0
- data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +134 -0
- data/lib/lighthouse/directive_resolvers/event_resolver.rb +37 -0
- data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +28 -13
- data/lib/lighthouse/directive_resolvers/soft_delete_resolvers.rb +66 -0
- data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +68 -0
- data/lib/lighthouse/directives/arguments/soft_deletes.rb +112 -0
- data/lib/lighthouse/directives/registry.rb +14 -6
- data/lib/lighthouse/directives/soft_deletes_directive.rb +123 -0
- data/lib/lighthouse/events/execution_trace.rb +51 -0
- data/lib/lighthouse/events/lifecycle.rb +146 -0
- data/lib/lighthouse/events/schema_build.rb +44 -0
- data/lib/lighthouse/events.rb +76 -0
- data/lib/lighthouse/feature_directive.rb +137 -0
- data/lib/lighthouse/field_resolver_stack.rb +22 -2
- data/lib/lighthouse/graphql/version.rb +1 -1
- data/lib/lighthouse/namespaces.rb +1 -0
- data/lib/lighthouse/persisted_queries/batch.rb +34 -0
- data/lib/lighthouse/persisted_queries.rb +17 -4
- data/lib/lighthouse/rb_lighthouse.rb +20 -0
- data/lib/lighthouse/schema_cache.rb +12 -5
- data/lib/lighthouse/schema_generator.rb +27 -2
- data/lib/lighthouse/subscription.rb +120 -0
- data/lib/lighthouse/support/soft_deletes.rb +164 -0
- data/lib/lighthouse/tasks/lighthouse.rake +10 -0
- data/sig/lighthouse/controller.rbs +30 -0
- data/sig/lighthouse/directives.rbs +158 -0
- data/sig/lighthouse/events.rbs +122 -0
- data/sig/lighthouse/graphql.rbs +111 -0
- data/sig/lighthouse.rbs +193 -0
- 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
|
-
|
|
22
|
-
|
|
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
|
data/lib/lighthouse/config.rb
CHANGED
|
@@ -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 ||=
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
189
|
+
# The callables the authorizer and on_error settings fall back to.
|
|
190
|
+
module Defaults
|
|
191
|
+
module_function
|
|
151
192
|
|
|
152
|
-
def
|
|
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
|
|
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
|