lighthouse-graphql 0.2.1 → 0.4.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 +99 -1
- data/README.md +22 -3
- data/docs/best-practices.md +16 -6
- data/docs/caching.md +1 -1
- data/docs/configuration.md +74 -5
- data/docs/custom-directives.md +11 -1
- data/docs/directives.md +333 -2
- data/docs/events.md +203 -0
- data/docs/federation.md +3 -2
- data/docs/getting-started.md +90 -23
- data/docs/motivation.md +3 -2
- data/docs/mutations.md +15 -0
- data/docs/resolvers.md +95 -19
- data/docs/security.md +210 -0
- data/docs/subscriptions.md +446 -0
- data/lib/generators/lighthouse/base_classes.rb +87 -0
- data/lib/generators/lighthouse/class_generator.rb +19 -8
- data/lib/generators/lighthouse/install/install_generator.rb +23 -6
- data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +7 -45
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +34 -5
- data/lib/generators/lighthouse/install/templates/schema.rb.tt +14 -2
- data/lib/generators/lighthouse/mutation_generator.rb +5 -4
- data/lib/generators/lighthouse/query_generator.rb +5 -4
- data/lib/generators/lighthouse/subscription_generator.rb +100 -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/complexity.rb +174 -0
- data/lib/lighthouse/complexity_analyzer.rb +56 -0
- data/lib/lighthouse/config.rb +56 -14
- data/lib/lighthouse/controller.rb +131 -0
- data/lib/lighthouse/directive_resolvers/broadcast_resolver.rb +31 -0
- data/lib/lighthouse/directive_resolvers/convention_resolver.rb +14 -4
- 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 +76 -0
- data/lib/lighthouse/directives/arguments/soft_deletes.rb +112 -0
- data/lib/lighthouse/directives/registry.rb +15 -6
- data/lib/lighthouse/directives/soft_deletes_directive.rb +123 -0
- data/lib/lighthouse/events/execution_trace.rb +52 -0
- data/lib/lighthouse/events/lifecycle.rb +148 -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 +28 -7
- 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 +23 -0
- data/lib/lighthouse/resolver_bases.rb +61 -0
- data/lib/lighthouse/schema_cache.rb +12 -5
- data/lib/lighthouse/schema_factory.rb +0 -1
- data/lib/lighthouse/schema_generator.rb +42 -9
- data/lib/lighthouse/sdl_loader.rb +0 -2
- data/lib/lighthouse/subscription.rb +120 -0
- data/lib/lighthouse/support/model_resolver.rb +1 -1
- 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/events.rbs +122 -0
- data/sig/lighthouse/graphql.rbs +62 -0
- data/sig/lighthouse.rbs +52 -1
- metadata +29 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/notifications'
|
|
4
|
+
|
|
5
|
+
# A small, framework-agnostic event dispatcher: PHP Lighthouse's events, which
|
|
6
|
+
# let an app hook into the request lifecycle and the schema build without
|
|
7
|
+
# patching either (see docs/events.md).
|
|
8
|
+
#
|
|
9
|
+
# Lighthouse::Events.listen(Lighthouse::Events::BuildSchemaString) do |event|
|
|
10
|
+
# 'extend type Query { version: String }'
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# A listener is anything that responds to `call(event)`. Every dispatch also
|
|
14
|
+
# goes through ActiveSupport::Notifications, as
|
|
15
|
+
# "<event name underscored>.lighthouse" (`start_execution.lighthouse`), with
|
|
16
|
+
# the event as the `:event` payload, so Rails tooling can observe them too.
|
|
17
|
+
module Lighthouse::Events
|
|
18
|
+
# Base class of the events Lighthouse dispatches; a listener on it hears all
|
|
19
|
+
# of them. Events that carry a time expose it as `moment`.
|
|
20
|
+
class Event
|
|
21
|
+
attr_reader :moment
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@moment = Time.now.utc
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@listeners = [].freeze
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# Register a listener (a callable or a block) for `event_class` and its
|
|
33
|
+
# subclasses. Returns the listener, to hand to `forget`.
|
|
34
|
+
def listen(event_class, callable = nil, &block)
|
|
35
|
+
listener = callable || block
|
|
36
|
+
raise ArgumentError, 'Lighthouse::Events.listen needs a callable or a block' unless listener.respond_to?(:call)
|
|
37
|
+
|
|
38
|
+
@mutex.synchronize { @listeners = [*@listeners, [event_class, listener]].freeze }
|
|
39
|
+
listener
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Unregister a listener (the value `listen` returned) from every event.
|
|
43
|
+
def forget(listener)
|
|
44
|
+
@mutex.synchronize { @listeners = @listeners.reject { |_, registered| registered.equal?(listener) }.freeze }
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Drop every listener (between specs).
|
|
49
|
+
def reset!
|
|
50
|
+
@mutex.synchronize { @listeners = [].freeze }
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Call the listeners of the event's class and its ancestors, in
|
|
55
|
+
# registration order. Returns their non-nil return values; what a
|
|
56
|
+
# return value means is up to the event.
|
|
57
|
+
def dispatch(event)
|
|
58
|
+
::ActiveSupport::Notifications.instrument(notification_name(event.class), event: event) do
|
|
59
|
+
@listeners.filter_map { |klass, listener| listener.call(event) if event.is_a?(klass) }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Whether dispatching an `event_class` event would reach anyone: a
|
|
64
|
+
# listener, or an ActiveSupport::Notifications subscriber. Hot paths ask
|
|
65
|
+
# first, to cost nothing when no one listens.
|
|
66
|
+
def listening?(event_class)
|
|
67
|
+
@listeners.any? { |klass, _| event_class <= klass } ||
|
|
68
|
+
::ActiveSupport::Notifications.notifier.listening?(notification_name(event_class))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The ActiveSupport::Notifications name an event class is instrumented as.
|
|
72
|
+
def notification_name(event_class)
|
|
73
|
+
"#{(event_class.name || 'event').demodulize.underscore}.lighthouse"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# @feature — include a field or an object type in the schema depending on a
|
|
4
|
+
# feature flag (PHP Lighthouse's Laravel Pennant integration, here backed by
|
|
5
|
+
# Flipper).
|
|
6
|
+
#
|
|
7
|
+
# Flags are per request and per actor while the schema is built once, so the
|
|
8
|
+
# element is not removed from the schema: it is hidden through graphql-ruby's
|
|
9
|
+
# per-query visibility. Its `visible?(context)` answers whether the flag's
|
|
10
|
+
# state matches `when` (ACTIVE by default). A hidden field disappears from
|
|
11
|
+
# introspection and a query selecting it fails validation, as if the SDL did
|
|
12
|
+
# not declare it; the fields returning a hidden type are hidden with it.
|
|
13
|
+
#
|
|
14
|
+
# The state is read through Config.feature_checker for the actor of
|
|
15
|
+
# Config.feature_actor, at most once per feature and query (memoized in the
|
|
16
|
+
# query's context), however many times graphql-ruby asks.
|
|
17
|
+
#
|
|
18
|
+
# Printing the schema (`to_definition`) is a query of its own: it shows the
|
|
19
|
+
# features as they stand with no actor. Only graphql-ruby's own null context
|
|
20
|
+
# (preloading a visibility profile) hides nothing.
|
|
21
|
+
module Lighthouse::GraphQL::FeatureDirective
|
|
22
|
+
# Arguments with a default are nullable, not `Type! = default` as in PHP: graphql-ruby
|
|
23
|
+
# 2.5 does not fill in a directive argument's default, so a non-null one would be
|
|
24
|
+
# refused wherever it is left out. Left out or null, it takes its default.
|
|
25
|
+
DEFINITION = 'directive @feature(name: String!, when: FeatureState = ACTIVE) on FIELD_DEFINITION | OBJECT'
|
|
26
|
+
|
|
27
|
+
TYPE_DEFINITIONS = {
|
|
28
|
+
'FeatureState' => <<~GRAPHQL.chomp
|
|
29
|
+
"Options for the `when` argument of `@feature`."
|
|
30
|
+
enum FeatureState {
|
|
31
|
+
"Indicates an active feature."
|
|
32
|
+
ACTIVE
|
|
33
|
+
"Indicates an inactive feature."
|
|
34
|
+
INACTIVE
|
|
35
|
+
}
|
|
36
|
+
GRAPHQL
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
STATES = %w[ACTIVE INACTIVE].freeze
|
|
40
|
+
CONTEXT_KEY = :lighthouse_features
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
# Config.feature_checker's default: ask Flipper.
|
|
45
|
+
def default_checker
|
|
46
|
+
lambda do |name, actor, _context|
|
|
47
|
+
raise 'Lighthouse: configure `Config.feature_checker` to use @feature (Flipper not detected).' unless defined?(::Flipper)
|
|
48
|
+
|
|
49
|
+
::Flipper.enabled?(name.to_sym, actor)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Hide the types and fields of the schema that carry @feature.
|
|
54
|
+
def apply(schema)
|
|
55
|
+
used = false
|
|
56
|
+
schema.types.each_value do |type|
|
|
57
|
+
next unless type.kind.object? || type.kind.interface?
|
|
58
|
+
|
|
59
|
+
used |= hide_type(type)
|
|
60
|
+
type.fields.each_value { |field| used |= hide_field(type, field) }
|
|
61
|
+
end
|
|
62
|
+
silence_visibility_warning(schema) if used
|
|
63
|
+
schema
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Is the feature's state the one @feature asks for, in this query?
|
|
67
|
+
def visible?(name, state, context)
|
|
68
|
+
return true if context.nil? || context.is_a?(::GraphQL::Query::NullContext)
|
|
69
|
+
|
|
70
|
+
active = features(context).fetch(name) do
|
|
71
|
+
features(context)[name] = check(name, context)
|
|
72
|
+
end
|
|
73
|
+
(state == 'ACTIVE') == active
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def check(name, context)
|
|
77
|
+
actor = Lighthouse::Config.feature_actor.call(context)
|
|
78
|
+
Lighthouse::Config.feature_checker.call(name, actor, context) ? true : false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def features(context)
|
|
82
|
+
context[CONTEXT_KEY] ||= {}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def hide_type(type)
|
|
86
|
+
feature = requirement(type, type.graphql_name)
|
|
87
|
+
return false unless feature
|
|
88
|
+
|
|
89
|
+
name, state = feature
|
|
90
|
+
original = type.method(:visible?)
|
|
91
|
+
type.define_singleton_method(:visible?) do |context|
|
|
92
|
+
original.call(context) && Lighthouse::GraphQL::FeatureDirective.visible?(name, state, context)
|
|
93
|
+
end
|
|
94
|
+
true
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def hide_field(type, field)
|
|
98
|
+
feature = requirement(field, "#{type.graphql_name}.#{field.name}")
|
|
99
|
+
return false unless feature
|
|
100
|
+
|
|
101
|
+
name, state = feature
|
|
102
|
+
original = field.method(:visible?)
|
|
103
|
+
field.define_singleton_method(:visible?) do |context|
|
|
104
|
+
original.call(context) && Lighthouse::GraphQL::FeatureDirective.visible?(name, state, context)
|
|
105
|
+
end
|
|
106
|
+
true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# [feature name, required state] from the member's @feature, or nil.
|
|
110
|
+
def requirement(member, path)
|
|
111
|
+
node = member.ast_node&.directives&.find { |directive| directive.name == 'feature' }
|
|
112
|
+
return unless node
|
|
113
|
+
|
|
114
|
+
name = Lighthouse::Support::DirectiveArgs.literal(node, 'name').to_s
|
|
115
|
+
raise Lighthouse::DefinitionError, "@feature on #{path} needs a feature `name`" if name.strip.empty?
|
|
116
|
+
|
|
117
|
+
[name, required_state(node, path)]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def required_state(node, path)
|
|
121
|
+
state = Lighthouse::Support::DirectiveArgs.literal(node, 'when') || 'ACTIVE'
|
|
122
|
+
return state if STATES.include?(state)
|
|
123
|
+
|
|
124
|
+
raise Lighthouse::DefinitionError, "@feature on #{path}: `when` must be ACTIVE or INACTIVE, got #{state}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# graphql-ruby 2.6 warns, once per query, when a member is hidden and the
|
|
128
|
+
# schema configured neither Visibility nor Warden. @feature is configured
|
|
129
|
+
# on purpose: keep the schema on the default (Warden) unless it chose.
|
|
130
|
+
def silence_visibility_warning(schema)
|
|
131
|
+
chosen = [::GraphQL::Schema::Warden, (::GraphQL::Schema::Visibility if defined?(::GraphQL::Schema::Visibility))]
|
|
132
|
+
return if schema.plugins.any? { |(plugin, _options)| chosen.include?(plugin) }
|
|
133
|
+
return unless ::GraphQL::Schema::Warden.respond_to?(:use)
|
|
134
|
+
|
|
135
|
+
schema.use(::GraphQL::Schema::Warden)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -3,18 +3,20 @@
|
|
|
3
3
|
# Install a field's resolvers innermost first (see
|
|
4
4
|
# DirectiveRegistry#resolution_order), so each wrapper decorates the ones
|
|
5
5
|
# before it. Besides its directives, a field gets the conventional
|
|
6
|
-
#
|
|
6
|
+
# Resolvers::/Mutations:: resolver when it is a root field with no resolving
|
|
7
7
|
# directive, and argument validation when an input type it takes carries
|
|
8
8
|
# @validator. The registry replaces a hardcoded `case dnode.name`: adding a
|
|
9
9
|
# directive is "register a class", not "edit the generator".
|
|
10
10
|
module Lighthouse::GraphQL::FieldResolverStack
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
# `bases`: { root kind => base class } from the schema's base_types (ResolverBases).
|
|
14
|
+
def apply(type, field_def, directives, root_kind, bases: {})
|
|
15
|
+
opts = { root_kind: root_kind, bases: bases }
|
|
15
16
|
steps = directive_steps(type, field_def, directives, opts)
|
|
16
|
-
steps << convention_step(type, field_def, root_kind) if root_kind && !resolving_directive?(directives)
|
|
17
|
+
steps << convention_step(type, field_def, root_kind, bases) if root_kind && !resolving_directive?(directives)
|
|
17
18
|
steps << validator_step(type, field_def, opts) if input_validation?(field_def, directives)
|
|
19
|
+
steps << empty_strings_step(type, field_def, opts) if empty_string_conversion?(field_def, directives)
|
|
18
20
|
|
|
19
21
|
steps.each_with_index.sort_by { |(order, _), index| [order, index] }.each { |(_, step), _| step.call }
|
|
20
22
|
end
|
|
@@ -37,9 +39,15 @@ module Lighthouse::GraphQL::FieldResolverStack
|
|
|
37
39
|
directives.any? { |d| registry.resolution_class(d.name) && !registry.wrapper?(d.name) }
|
|
38
40
|
end
|
|
39
41
|
|
|
40
|
-
def convention_step(type, field_def, root_kind)
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
def convention_step(type, field_def, root_kind, bases = {})
|
|
43
|
+
resolvers = Lighthouse::GraphQL::DirectiveResolvers
|
|
44
|
+
resolver =
|
|
45
|
+
if root_kind == :subscriptions
|
|
46
|
+
resolvers::SubscriptionDirectiveResolver.new(field_def, type, nil, root_kind: root_kind, bases: bases)
|
|
47
|
+
else
|
|
48
|
+
resolvers::ConventionResolver.new(field_def, type, root_kind, bases[root_kind])
|
|
49
|
+
end
|
|
50
|
+
[0, -> { resolver.define_resolver(type) }]
|
|
43
51
|
end
|
|
44
52
|
|
|
45
53
|
# An explicit @validator on the field already validates its arguments.
|
|
@@ -53,4 +61,17 @@ module Lighthouse::GraphQL::FieldResolverStack
|
|
|
53
61
|
[Lighthouse::DirectiveRegistry.default.resolution_order('validator'),
|
|
54
62
|
-> { resolver.new(field_def, type, nil, opts).define_resolver(type) }]
|
|
55
63
|
end
|
|
64
|
+
|
|
65
|
+
# Without @convertEmptyStringsToNull on the field itself, an argument or input
|
|
66
|
+
# type that carries it (or Config.convert_empty_strings_to_null) still converts.
|
|
67
|
+
def empty_string_conversion?(field_def, directives)
|
|
68
|
+
directives.none? { |d| d.name == 'convertEmptyStringsToNull' } &&
|
|
69
|
+
Lighthouse::GraphQL::DirectiveResolvers::ConvertEmptyStringsToNullDirectiveResolver.implicit?(field_def)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def empty_strings_step(type, field_def, opts)
|
|
73
|
+
resolver = Lighthouse::GraphQL::DirectiveResolvers::ConvertEmptyStringsToNullDirectiveResolver
|
|
74
|
+
[Lighthouse::DirectiveRegistry.default.resolution_order(resolver.directive_name),
|
|
75
|
+
-> { resolver.new(field_def, type, nil, opts).define_resolver(type) }]
|
|
76
|
+
end
|
|
56
77
|
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A batch of operations (a JSON array in the request) run in one go through
|
|
4
|
+
# `schema.multiplex`: the query each operation (a Hash of query, extensions,
|
|
5
|
+
# variables, operation_name and context) names, resolved as PersistedQueries.execute
|
|
6
|
+
# does. Answers one result per operation, in order; an operation the protocol
|
|
7
|
+
# rejects gets its ready-to-render error Hash. Dispatches the Operation(s)
|
|
8
|
+
# events with the Arrays of operations and results.
|
|
9
|
+
module Lighthouse::GraphQL::PersistedQueries::Batch
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def run(schema, operations, **options)
|
|
13
|
+
operations = operations.map { |operation| operation.to_h.symbolize_keys }
|
|
14
|
+
Lighthouse::Events.dispatch(Lighthouse::Events::StartOperationOrOperations.new(operations))
|
|
15
|
+
rejected = {}
|
|
16
|
+
queries = operations.each_with_index.filter_map { |operation, index| query(operation, index, rejected) }
|
|
17
|
+
ran = schema.multiplex(queries, **options).to_a
|
|
18
|
+
results = operations.each_index.map { |index| rejected.fetch(index) { ran.shift } }
|
|
19
|
+
Lighthouse::Events.dispatch(Lighthouse::Events::EndOperationOrOperations.new(results))
|
|
20
|
+
results
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# One operation as `schema.multiplex` takes it; a rejected one is noted
|
|
24
|
+
# under its index instead.
|
|
25
|
+
def query(operation, index, rejected)
|
|
26
|
+
{
|
|
27
|
+
query: Lighthouse::GraphQL::PersistedQueries.resolve(operation[:query], operation[:extensions]),
|
|
28
|
+
variables: operation[:variables] || {}, operation_name: operation[:operation_name], context: operation[:context] || {}
|
|
29
|
+
}
|
|
30
|
+
rescue Lighthouse::GraphQL::PersistedQueries::Error => e
|
|
31
|
+
rejected[index] = e.to_h
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -49,11 +49,24 @@ module Lighthouse::GraphQL::PersistedQueries
|
|
|
49
49
|
module_function
|
|
50
50
|
|
|
51
51
|
# `schema.execute` with the query the request names; a protocol error
|
|
52
|
-
# comes back as a ready-to-render { "errors" => [...] } Hash.
|
|
52
|
+
# comes back as a ready-to-render { "errors" => [...] } Hash. Dispatches
|
|
53
|
+
# StartOperationOrOperations before and EndOperationOrOperations after.
|
|
53
54
|
def execute(schema, query:, extensions: nil, **options)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
operation = { query: query, variables: options[:variables], operation_name: options[:operation_name], extensions: extensions }
|
|
56
|
+
Lighthouse::Events.dispatch(Lighthouse::Events::StartOperationOrOperations.new(operation))
|
|
57
|
+
result =
|
|
58
|
+
begin
|
|
59
|
+
schema.execute(resolve(query, extensions), **options)
|
|
60
|
+
rescue Error => e
|
|
61
|
+
e.to_h
|
|
62
|
+
end
|
|
63
|
+
Lighthouse::Events.dispatch(Lighthouse::Events::EndOperationOrOperations.new(result))
|
|
64
|
+
result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A batch (a JSON array in the request) through `schema.multiplex`; see Batch.
|
|
68
|
+
def multiplex(schema, operations, **options)
|
|
69
|
+
Lighthouse::GraphQL::PersistedQueries::Batch.run(schema, operations, **options)
|
|
57
70
|
end
|
|
58
71
|
|
|
59
72
|
# The query text to run for a request's `query` and `extensions`.
|
|
@@ -28,6 +28,7 @@ require_relative 'namespaces'
|
|
|
28
28
|
support/authorization
|
|
29
29
|
support/arguments
|
|
30
30
|
support/batching
|
|
31
|
+
support/soft_deletes
|
|
31
32
|
where_conditions/operator_map
|
|
32
33
|
contracts/directive
|
|
33
34
|
contracts/field_resolver
|
|
@@ -39,19 +40,32 @@ require_relative 'namespaces'
|
|
|
39
40
|
generated_resolvers
|
|
40
41
|
federation_directives
|
|
41
42
|
field_resolver_stack
|
|
43
|
+
events
|
|
44
|
+
events/lifecycle
|
|
45
|
+
events/execution_trace
|
|
46
|
+
events/schema_build
|
|
47
|
+
resolver_bases
|
|
42
48
|
schema_generator
|
|
43
49
|
schema_cache
|
|
44
50
|
persisted_queries
|
|
51
|
+
persisted_queries/batch
|
|
52
|
+
controller
|
|
45
53
|
schema_factory
|
|
46
54
|
schema_implementation
|
|
47
55
|
resolver_dispatch
|
|
48
56
|
caching
|
|
49
57
|
cache_control
|
|
58
|
+
feature_directive
|
|
59
|
+
complexity
|
|
60
|
+
complexity_analyzer
|
|
61
|
+
broadcasting
|
|
62
|
+
subscription
|
|
50
63
|
pagination
|
|
51
64
|
relation_batch
|
|
52
65
|
nested_mutations
|
|
53
66
|
directives/pagination_manipulator
|
|
54
67
|
directives/where_conditions_directive
|
|
68
|
+
directives/soft_deletes_directive
|
|
55
69
|
directive_resolvers/aggregate_resolver
|
|
56
70
|
directive_resolvers/field_resolver
|
|
57
71
|
directive_resolvers/where_conditions_applier
|
|
@@ -70,11 +84,17 @@ require_relative 'namespaces'
|
|
|
70
84
|
directive_resolvers/can_resolvers
|
|
71
85
|
directive_resolvers/convention_resolver
|
|
72
86
|
directive_resolvers/model_mutation_resolver
|
|
87
|
+
directive_resolvers/soft_delete_resolvers
|
|
73
88
|
directive_resolvers/cache_resolvers
|
|
74
89
|
directive_resolvers/validator_resolver
|
|
90
|
+
directive_resolvers/subscription_resolver
|
|
91
|
+
directive_resolvers/broadcast_resolver
|
|
92
|
+
directive_resolvers/event_resolver
|
|
93
|
+
directive_resolvers/convert_empty_strings_resolver
|
|
75
94
|
directives/arguments/base
|
|
76
95
|
directives/arguments/filters
|
|
77
96
|
directives/arguments/order_by
|
|
97
|
+
directives/arguments/soft_deletes
|
|
78
98
|
directives/registry
|
|
79
99
|
dataloader_sources/association_loader
|
|
80
100
|
dataloader_sources/model_loader
|
|
@@ -87,6 +107,9 @@ rescue LoadError => e
|
|
|
87
107
|
warn("Lighthouse: could not require #{rel}: #{e.message}")
|
|
88
108
|
end
|
|
89
109
|
|
|
110
|
+
# The activejob gem is optional: the job that queues @broadcast loads on first use.
|
|
111
|
+
Lighthouse::GraphQL.autoload(:BroadcastJob, File.expand_path('broadcast_job.rb', __dir__))
|
|
112
|
+
|
|
90
113
|
require_relative 'railtie' if defined?(Rails::Railtie)
|
|
91
114
|
|
|
92
115
|
module Lighthouse::GraphQL::RbLightHouse
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The base classes of the classes root fields resolve to, given to `get_schema`
|
|
4
|
+
# beside graphql-ruby's own base types:
|
|
5
|
+
#
|
|
6
|
+
# Lighthouse::GraphQL::RbLightHouse.get_schema(options: { base_types: {
|
|
7
|
+
# resolver: Resolvers::BaseResolver, mutation: Mutations::BaseMutation,
|
|
8
|
+
# subscription: Subscriptions::BaseSubscription, object: Types::BaseObject
|
|
9
|
+
# } })
|
|
10
|
+
#
|
|
11
|
+
# graphql-ruby's `from_definition` knows only the SDL's own kinds (object,
|
|
12
|
+
# interface, union, scalar, enum, input_object), so these three are taken out
|
|
13
|
+
# before it sees the rest. Each one given:
|
|
14
|
+
#
|
|
15
|
+
# - is the class every class of its kind must inherit from: a Query, Mutation or
|
|
16
|
+
# Subscription field's class, found by convention or through
|
|
17
|
+
# @subscription(class:), that does not raises Lighthouse::DefinitionError when
|
|
18
|
+
# it is first used, so none misses what the base shares;
|
|
19
|
+
# - for subscriptions, also resolves a Subscription field that has no class of
|
|
20
|
+
# its own, instead of Lighthouse::GraphQL::Subscription::Default.
|
|
21
|
+
module Lighthouse::GraphQL::ResolverBases
|
|
22
|
+
# base_types key => the graphql-ruby class the base must inherit from.
|
|
23
|
+
GRAPHQL_CLASSES = {
|
|
24
|
+
resolver: ::GraphQL::Schema::Resolver,
|
|
25
|
+
mutation: ::GraphQL::Schema::Mutation,
|
|
26
|
+
subscription: ::GraphQL::Schema::Subscription
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# Root kind => base_types key.
|
|
30
|
+
KEYS = { queries: :resolver, mutations: :mutation, subscriptions: :subscription }.freeze
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# Take the three keys out of opts[:base_types] (the caller's Hash is not
|
|
35
|
+
# changed) and return { root kind => base class } for those given.
|
|
36
|
+
def extract!(opts)
|
|
37
|
+
base_types = opts[:base_types]
|
|
38
|
+
return {} unless base_types.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
given = base_types.slice(*GRAPHQL_CLASSES.keys).compact
|
|
41
|
+
opts[:base_types] = base_types.except(*GRAPHQL_CLASSES.keys)
|
|
42
|
+
given.each { |key, klass| validate!(key, klass) }
|
|
43
|
+
KEYS.filter_map { |kind, key| [kind, given[key]] if given[key] }.to_h
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate!(key, klass)
|
|
47
|
+
expected = GRAPHQL_CLASSES.fetch(key)
|
|
48
|
+
return if klass.is_a?(Class) && klass < expected
|
|
49
|
+
|
|
50
|
+
raise Lighthouse::DefinitionError, "base_types[:#{key}] must be a subclass of #{expected}, not #{klass.inspect}."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Raise unless `klass`, which resolves the field at `location`, inherits from
|
|
54
|
+
# `base` (nil: no base was given).
|
|
55
|
+
def check!(klass, base, location)
|
|
56
|
+
return if base.nil? || klass <= base
|
|
57
|
+
|
|
58
|
+
raise Lighthouse::DefinitionError,
|
|
59
|
+
"#{klass} resolves #{location}, so it must inherit from #{base}, the schema's base class for it (base_types)."
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -40,15 +40,22 @@ module Lighthouse::GraphQL::SchemaCache
|
|
|
40
40
|
|
|
41
41
|
# The parsed document for `sdl` after manipulation. `build` turns the SDL
|
|
42
42
|
# into the manipulated SDL string; it only runs on a cache miss.
|
|
43
|
-
# `force:` caches even when the cache is off (warm!).
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
# `force:` caches even when the cache is off (warm!). `finalize:` (a callable
|
|
44
|
+
# taking and returning the document) runs on the parsed document of a miss,
|
|
45
|
+
# before it is cached.
|
|
46
|
+
def document(sdl, force: false, finalize: nil, &build)
|
|
47
|
+
return built(sdl, finalize, &build) unless force || enabled?
|
|
46
48
|
|
|
47
49
|
path = File.join(directory.to_s, "#{PREFIX}#{key(sdl)}.bin")
|
|
48
50
|
cached = read(path)
|
|
49
51
|
return cached if cached
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
built(sdl, finalize, &build).tap { |document| write(path, document) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def built(sdl, finalize)
|
|
57
|
+
document = parse(yield(sdl))
|
|
58
|
+
finalize ? finalize.call(document) : document
|
|
52
59
|
end
|
|
53
60
|
|
|
54
61
|
def key(sdl)
|
|
@@ -73,7 +80,7 @@ module Lighthouse::GraphQL::SchemaCache
|
|
|
73
80
|
# and return the cache file's path.
|
|
74
81
|
def warm!(sdl = nil)
|
|
75
82
|
sdl ||= Lighthouse::GraphQL::SDLLoader.load_from_folder(Lighthouse::Config.sdl_folder.to_s)
|
|
76
|
-
|
|
83
|
+
Lighthouse::GraphQL::SchemaGenerator.build_document(sdl, force: true)
|
|
77
84
|
File.join(directory.to_s, "#{PREFIX}#{key(sdl)}.bin")
|
|
78
85
|
end
|
|
79
86
|
|
|
@@ -30,7 +30,6 @@ module Lighthouse::GraphQL::SchemaFactory
|
|
|
30
30
|
folder = sdl_folder || (Lighthouse::GraphQL::Config.sdl_folder if defined?(Lighthouse::GraphQL::Config))
|
|
31
31
|
return Lighthouse::GraphQL::SDLLoader.load_from_folder(folder) if folder && Dir.exist?(folder)
|
|
32
32
|
|
|
33
|
-
# fallback to apps/chatwoot/schema.graphql
|
|
34
33
|
File.read(File.expand_path('../../schema.graphql', __dir__))
|
|
35
34
|
end
|
|
36
35
|
end
|
|
@@ -43,18 +43,23 @@ module Lighthouse::GraphQL::SchemaGenerator
|
|
|
43
43
|
uses_cache_control = sdl_string.to_s.gsub(/directive\s+@cacheControl/, '').include?('@cacheControl')
|
|
44
44
|
# The manipulated document — from the schema cache when it is enabled
|
|
45
45
|
# and fresh (see SchemaCache), else built now.
|
|
46
|
-
document =
|
|
46
|
+
document = build_document(sdl_string)
|
|
47
47
|
impl = default_implementation
|
|
48
|
+
# base_types' resolver / mutation / subscription are Lighthouse's, not graphql-ruby's.
|
|
49
|
+
bases = Lighthouse::GraphQL::ResolverBases.extract!(opts)
|
|
48
50
|
schema = from_document(document, impl, opts)
|
|
49
51
|
|
|
50
52
|
delegate_to_federation_sdl(schema)
|
|
51
53
|
Lighthouse::GraphQL::GeneratedResolvers.install_dispatcher(schema)
|
|
52
54
|
attach_schema(impl, schema)
|
|
53
|
-
post_process(schema, impl, **opts)
|
|
55
|
+
post_process(schema, impl, bases: bases, **opts)
|
|
56
|
+
schema.trace_with(Lighthouse::Events::ExecutionTrace)
|
|
54
57
|
|
|
55
58
|
# @cacheControl hints become the response's Cache-Control header,
|
|
56
59
|
# computed per query from the fields it selects.
|
|
57
60
|
schema.query_analyzer(Lighthouse::GraphQL::CacheControl::Analyzer) if uses_cache_control
|
|
61
|
+
# The score graphql-ruby checks against max_complexity, for BuildExtensionsResponse.
|
|
62
|
+
schema.query_analyzer(Lighthouse::GraphQL::Complexity::Analyzer)
|
|
58
63
|
|
|
59
64
|
schema
|
|
60
65
|
end
|
|
@@ -104,6 +109,18 @@ module Lighthouse::GraphQL::SchemaGenerator
|
|
|
104
109
|
Module.new { define_singleton_method(:parse) { |*| document } }
|
|
105
110
|
end
|
|
106
111
|
|
|
112
|
+
# The manipulated schema document for the app's SDL, from the schema cache
|
|
113
|
+
# when it is enabled and fresh. The build events fire around the phases:
|
|
114
|
+
# RegisterDirectiveNamespaces on every build (the cache key covers the
|
|
115
|
+
# registered directives), BuildSchemaString and ManipulateAST only when the
|
|
116
|
+
# document is built, i.e. on a cache miss.
|
|
117
|
+
def build_document(sdl, force: false)
|
|
118
|
+
Lighthouse::Events::SchemaBuild.register_directive_namespaces
|
|
119
|
+
Lighthouse::GraphQL::SchemaCache.document(sdl, force: force, finalize: Lighthouse::Events::SchemaBuild.method(:manipulate_ast)) do |source|
|
|
120
|
+
manipulate(Lighthouse::Events::SchemaBuild.schema_string(source))
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
107
124
|
# The SDL as the schema is built from it: directive definitions injected
|
|
108
125
|
# (so apps don't hand-declare Lighthouse's directives), then every
|
|
109
126
|
# registered manipulator run (pagination types, whereConditions inputs, ...).
|
|
@@ -111,8 +128,9 @@ module Lighthouse::GraphQL::SchemaGenerator
|
|
|
111
128
|
augment_sdl_with_connections(inject_directive_definitions(sdl))
|
|
112
129
|
end
|
|
113
130
|
|
|
114
|
-
|
|
115
|
-
|
|
131
|
+
# `bases`: { root kind => the base class its fields' classes inherit from } (ResolverBases).
|
|
132
|
+
def post_process(schema, _impl, bases: {}, **_opts)
|
|
133
|
+
root_kinds = root_kinds(schema)
|
|
116
134
|
|
|
117
135
|
# Inspect types and fields for directives and attach generated resolvers
|
|
118
136
|
schema.types.each_value do |type|
|
|
@@ -121,19 +139,34 @@ module Lighthouse::GraphQL::SchemaGenerator
|
|
|
121
139
|
# Apply object-level federation directives to generated types so the
|
|
122
140
|
# Apollo Federation helper can detect entities and add _entities.
|
|
123
141
|
Lighthouse::GraphQL::FederationDirectives.apply(type)
|
|
124
|
-
type.fields.each_value { |field_def| process_field(type, field_def, root_kinds[type.graphql_name]) }
|
|
142
|
+
type.fields.each_value { |field_def| process_field(type, field_def, root_kinds[type.graphql_name], bases) }
|
|
125
143
|
end
|
|
126
144
|
|
|
145
|
+
# @complexity, and paginated fields, give graphql-ruby's complexity analysis their cost.
|
|
146
|
+
Lighthouse::GraphQL::Complexity.apply(schema, root_kinds)
|
|
147
|
+
|
|
148
|
+
# @feature hides a field or type per query, through graphql-ruby's visibility.
|
|
149
|
+
Lighthouse::GraphQL::FeatureDirective.apply(schema)
|
|
150
|
+
|
|
151
|
+
# Every @broadcast must name a field of the Subscription type.
|
|
152
|
+
Lighthouse::GraphQL::Broadcasting.validate!(schema)
|
|
153
|
+
|
|
127
154
|
# return schema for convenience
|
|
128
155
|
schema
|
|
129
156
|
end
|
|
130
157
|
|
|
158
|
+
# { type name => :queries | :mutations | :subscriptions } for the schema's root types.
|
|
159
|
+
def root_kinds(schema)
|
|
160
|
+
{ schema.query => :queries, schema.mutation => :mutations, schema.subscription => :subscriptions }
|
|
161
|
+
.reject { |type, _| type.nil? }.transform_keys(&:graphql_name)
|
|
162
|
+
end
|
|
163
|
+
|
|
131
164
|
# A field's generated resolvers, then the runtime hooks that make
|
|
132
165
|
# graphql-ruby reach them.
|
|
133
|
-
def process_field(type, field_def, root_kind)
|
|
166
|
+
def process_field(type, field_def, root_kind, bases = {})
|
|
134
167
|
return unless field_def.ast_node
|
|
135
168
|
|
|
136
|
-
apply_field_resolvers(type, field_def, field_def.ast_node.directives || [], root_kind)
|
|
169
|
+
apply_field_resolvers(type, field_def, field_def.ast_node.directives || [], root_kind, bases)
|
|
137
170
|
Lighthouse::GraphQL::GeneratedResolvers.route_runtime_resolver(type, field_def)
|
|
138
171
|
ensure_connection_extension(field_def)
|
|
139
172
|
end
|
|
@@ -155,8 +188,8 @@ module Lighthouse::GraphQL::SchemaGenerator
|
|
|
155
188
|
end
|
|
156
189
|
|
|
157
190
|
# A field's resolvers, installed in resolution order (see FieldResolverStack).
|
|
158
|
-
def apply_field_resolvers(type, field_def, directives, root_kind)
|
|
159
|
-
Lighthouse::GraphQL::FieldResolverStack.apply(type, field_def, directives, root_kind)
|
|
191
|
+
def apply_field_resolvers(type, field_def, directives, root_kind, bases = {})
|
|
192
|
+
Lighthouse::GraphQL::FieldResolverStack.apply(type, field_def, directives, root_kind, bases: bases)
|
|
160
193
|
end
|
|
161
194
|
|
|
162
195
|
# Scan SDL string for list-typed fields that carry relationship directives
|
|
@@ -6,8 +6,6 @@ module Lighthouse::GraphQL::SDLLoader
|
|
|
6
6
|
def load_from_folder(folder)
|
|
7
7
|
files = Dir[File.join(folder, '**', '*.graphql')]
|
|
8
8
|
if files.empty?
|
|
9
|
-
# fallback to the monolithic schema.graphql in apps/chatwoot
|
|
10
|
-
# schema.graphql is located at apps/chatwoot/schema.graphql
|
|
11
9
|
default = File.expand_path('../../..//schema.graphql', __dir__)
|
|
12
10
|
return File.read(default) if File.exist?(default)
|
|
13
11
|
|