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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -1
  3. data/README.md +22 -3
  4. data/docs/best-practices.md +16 -6
  5. data/docs/caching.md +1 -1
  6. data/docs/configuration.md +74 -5
  7. data/docs/custom-directives.md +11 -1
  8. data/docs/directives.md +333 -2
  9. data/docs/events.md +203 -0
  10. data/docs/federation.md +3 -2
  11. data/docs/getting-started.md +90 -23
  12. data/docs/motivation.md +3 -2
  13. data/docs/mutations.md +15 -0
  14. data/docs/resolvers.md +95 -19
  15. data/docs/security.md +210 -0
  16. data/docs/subscriptions.md +446 -0
  17. data/lib/generators/lighthouse/base_classes.rb +87 -0
  18. data/lib/generators/lighthouse/class_generator.rb +19 -8
  19. data/lib/generators/lighthouse/install/install_generator.rb +23 -6
  20. data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +7 -45
  21. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +34 -5
  22. data/lib/generators/lighthouse/install/templates/schema.rb.tt +14 -2
  23. data/lib/generators/lighthouse/mutation_generator.rb +5 -4
  24. data/lib/generators/lighthouse/query_generator.rb +5 -4
  25. data/lib/generators/lighthouse/subscription_generator.rb +100 -0
  26. data/lib/lighthouse/broadcast_job.rb +14 -0
  27. data/lib/lighthouse/broadcasting.rb +143 -0
  28. data/lib/lighthouse/cache_control.rb +5 -2
  29. data/lib/lighthouse/complexity.rb +174 -0
  30. data/lib/lighthouse/complexity_analyzer.rb +56 -0
  31. data/lib/lighthouse/config.rb +56 -14
  32. data/lib/lighthouse/controller.rb +131 -0
  33. data/lib/lighthouse/directive_resolvers/broadcast_resolver.rb +31 -0
  34. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +14 -4
  35. data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +134 -0
  36. data/lib/lighthouse/directive_resolvers/event_resolver.rb +37 -0
  37. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +28 -13
  38. data/lib/lighthouse/directive_resolvers/soft_delete_resolvers.rb +66 -0
  39. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +76 -0
  40. data/lib/lighthouse/directives/arguments/soft_deletes.rb +112 -0
  41. data/lib/lighthouse/directives/registry.rb +15 -6
  42. data/lib/lighthouse/directives/soft_deletes_directive.rb +123 -0
  43. data/lib/lighthouse/events/execution_trace.rb +52 -0
  44. data/lib/lighthouse/events/lifecycle.rb +148 -0
  45. data/lib/lighthouse/events/schema_build.rb +44 -0
  46. data/lib/lighthouse/events.rb +76 -0
  47. data/lib/lighthouse/feature_directive.rb +137 -0
  48. data/lib/lighthouse/field_resolver_stack.rb +28 -7
  49. data/lib/lighthouse/graphql/version.rb +1 -1
  50. data/lib/lighthouse/namespaces.rb +1 -0
  51. data/lib/lighthouse/persisted_queries/batch.rb +34 -0
  52. data/lib/lighthouse/persisted_queries.rb +17 -4
  53. data/lib/lighthouse/rb_lighthouse.rb +23 -0
  54. data/lib/lighthouse/resolver_bases.rb +61 -0
  55. data/lib/lighthouse/schema_cache.rb +12 -5
  56. data/lib/lighthouse/schema_factory.rb +0 -1
  57. data/lib/lighthouse/schema_generator.rb +42 -9
  58. data/lib/lighthouse/sdl_loader.rb +0 -2
  59. data/lib/lighthouse/subscription.rb +120 -0
  60. data/lib/lighthouse/support/model_resolver.rb +1 -1
  61. data/lib/lighthouse/support/soft_deletes.rb +164 -0
  62. data/lib/lighthouse/tasks/lighthouse.rake +10 -0
  63. data/sig/lighthouse/controller.rbs +30 -0
  64. data/sig/lighthouse/events.rbs +122 -0
  65. data/sig/lighthouse/graphql.rbs +62 -0
  66. data/sig/lighthouse.rbs +52 -1
  67. 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
- # Queries::/Mutations:: resolver when it is a root field with no resolving
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
- def apply(type, field_def, directives, root_kind)
14
- opts = { root_kind: root_kind }
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
- resolver = Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver
42
- [0, -> { resolver.new(field_def, type, root_kind).define_resolver(type) }]
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lighthouse
4
4
  module GraphQL
5
- VERSION = '0.2.1'
5
+ VERSION = '0.4.0'
6
6
  end
7
7
  end
@@ -6,6 +6,7 @@
6
6
  # here, first.
7
7
  module Lighthouse
8
8
  module Contracts; end
9
+ module Events; end
9
10
  module Generators; end
10
11
  module Support; end
11
12
  module WhereConditions; 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
- schema.execute(resolve(query, extensions), **options)
55
- rescue Error => e
56
- e.to_h
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
- def document(sdl, force: false, &build)
45
- return parse(yield(sdl)) unless force || enabled?
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
- parse(yield(sdl)).tap { |document| write(path, document) }
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
- document(sdl, force: true) { |source| Lighthouse::GraphQL::SchemaGenerator.manipulate(source) }
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 = Lighthouse::GraphQL::SchemaCache.document(sdl_string) { |sdl| manipulate(sdl) }
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
- def post_process(schema, _impl, **_opts)
115
- root_kinds = { schema.query&.graphql_name => :queries, schema.mutation&.graphql_name => :mutations }.reject { |name, _| name.nil? }
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