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
@@ -1,57 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Serves the schema: StartRequest/EndRequest events, persisted queries, batching,
4
+ # variables, Cache-Control. See docs/getting-started.md.
3
5
  class GraphqlController < ApplicationController
4
6
  # If this endpoint is called from outside this domain, null the session
5
7
  # instead of failing CSRF verification:
6
8
  # protect_from_forgery with: :null_session
7
9
 
8
- def execute
9
- # PersistedQueries.execute resolves Apollo-style persisted queries when
10
- # config.persisted_queries is on, and otherwise runs params[:query] as is.
11
- result = Lighthouse::GraphQL::PersistedQueries.execute(
12
- <%= schema_name %>,
13
- query: params[:query],
14
- extensions: params[:extensions],
15
- variables: prepare_variables(params[:variables]),
16
- operation_name: params[:operationName],
17
- context: {
18
- # Read by @guard and the @can* directives — see config/initializers/lighthouse.rb.
19
- # current_user: current_user
20
- }
21
- )
22
- # The Cache-Control header the selected fields' @cacheControl hints allow
23
- # (a no-op when the schema has none).
24
- Lighthouse::GraphQL::CacheControl.apply(response, result)
25
- render json: result
26
- rescue StandardError => e
27
- raise e unless Rails.env.development?
10
+ include Lighthouse::GraphQL::Controller
28
11
 
29
- handle_error_in_development(e)
30
- end
12
+ lighthouse_schema <%= schema_name %>
31
13
 
32
14
  private
33
15
 
34
- # Variables arrive as a JSON string (GET or form posts) or as parameters.
35
- def prepare_variables(variables_param)
36
- case variables_param
37
- when String
38
- variables_param.present? ? JSON.parse(variables_param) || {} : {}
39
- when Hash
40
- variables_param
41
- when ActionController::Parameters
42
- variables_param.to_unsafe_hash
43
- when nil
44
- {}
45
- else
46
- raise ArgumentError, "Unexpected parameter: #{variables_param}"
47
- end
48
- end
49
-
50
- def handle_error_in_development(error)
51
- logger.error(error.message)
52
- logger.error(error.backtrace.join("\n"))
53
-
54
- render json: { errors: [{ message: error.message, backtrace: error.backtrace }], data: {} },
55
- status: :internal_server_error
56
- end
16
+ # Read by @guard and the @can* directives — see config/initializers/lighthouse.rb.
17
+ # Defaults to { current_user: current_user } when the controller has a current_user.
18
+ # def graphql_context = { current_user: current_user }
57
19
  end
@@ -12,12 +12,16 @@ Lighthouse.configure do |config|
12
12
 
13
13
  # Where classes named by the schema are looked up, by kind. A Query or Mutation
14
14
  # field without a resolving directive resolves by convention: Query.latestPosts
15
- # to Queries::LatestPosts (a GraphQL::Schema::Resolver), Mutation.publishPost
16
- # to Mutations::PublishPost (a GraphQL::Schema::Mutation). @builder classes
17
- # come from :builders and @validator contracts from :validators. Generate them
18
- # with `rails g lighthouse:query|mutation|builder|validator Name`.
15
+ # to Resolvers::LatestPosts (a Resolvers::BaseResolver), Mutation.publishPost
16
+ # to Mutations::PublishPost (a Mutations::BaseMutation). @builder classes
17
+ # come from :builders and @validator contracts from :validators. A Subscription
18
+ # field resolves to Subscriptions::PostUpdated (a Subscriptions::BaseSubscription),
19
+ # @event(dispatch:) classes come from :events, and the graphql-ruby analyzers
20
+ # @complexity(resolver:) names from :analyzers. Generate them with
21
+ # `rails g lighthouse:query|mutation|subscription|builder|validator Name`.
19
22
  # config.namespaces = {
20
- # queries: ['Queries'], mutations: ['Mutations'], builders: ['Builders'], validators: ['Validators']
23
+ # queries: ['Resolvers'], mutations: ['Mutations'], subscriptions: ['Subscriptions'],
24
+ # builders: ['Builders'], validators: ['Validators'], events: ['Events'], analyzers: ['Analyzers']
21
25
  # }
22
26
 
23
27
  # Namespaces tried, in order, for models (@all(model: "User")) and for
@@ -31,6 +35,19 @@ Lighthouse.configure do |config|
31
35
  # Run @create/@update/@createMany/@updateMany in a database transaction.
32
36
  # config.transactional_mutations = true
33
37
 
38
+ # Treat "" as null on every field's inputs, as if each carried
39
+ # @convertEmptyStringsToNull. Read when the schema is built.
40
+ # config.convert_empty_strings_to_null = false
41
+
42
+ # What "trashed" means for @softDeletes, @trashed, @restore and @forceDelete.
43
+ # By default each model is inspected: paranoia, then discard, then a deleted_at column.
44
+ # config.soft_deletes = Lighthouse::Support::SoftDeletes::Column.new(:archived_at)
45
+
46
+ # Run @broadcast's subscription triggers in an ActiveJob (a directive's
47
+ # shouldQueue: overrides it), and the queue they go to (nil: the default one).
48
+ # config.queue_broadcasts = false
49
+ # config.broadcast_queue = nil
50
+
34
51
  # --- For large schemas and high traffic (all off by default) ---------------
35
52
  # Cache the built schema document in tmp/cache, keyed by the SDL and
36
53
  # everything that shapes it; warm it at deploy with `bin/rails lighthouse:cache`.
@@ -56,6 +73,11 @@ Lighthouse.configure do |config|
56
73
  # config.authenticated_user = ->(ctx) { ctx[:current_user] }
57
74
  # config.policy_user = ->(ctx) { ctx[:pundit_user] || ctx[:current_user] }
58
75
  # config.authorizer = ->(user, record, ability, args = []) { Pundit.policy!(user, record).public_send("#{ability}?", *args) }
76
+
77
+ # Feature flags read by @feature (Flipper by default): the actor a flag is
78
+ # checked for, and the check itself.
79
+ # config.feature_actor = ->(ctx) { ctx[:current_user] }
80
+ # config.feature_checker = ->(name, actor, _ctx) { Flipper.enabled?(name.to_sym, actor) }
59
81
  end
60
82
 
61
83
  # Register app-defined directives and federation reference resolvers inside
@@ -65,3 +87,10 @@ end
65
87
  # Lighthouse::DirectiveRegistry.register(Directives::Upcase)
66
88
  # Lighthouse::ReferenceResolver.register('User') { |ref, _ctx| User.find_by(id: ref['id']) }
67
89
  # end
90
+
91
+ # Listen to Lighthouse's request, schema-build and execution events. A listener
92
+ # may return a value where the event says so, as BuildExtensionsResponse does.
93
+ # See docs/events.md.
94
+ # Lighthouse::Events.listen(Lighthouse::Events::EndExecution) do |event|
95
+ # Rails.logger.info("[GraphQL] #{event.result.to_h['errors']&.size.to_i} errors")
96
+ # end
@@ -2,9 +2,21 @@
2
2
 
3
3
  # Executable schema built by Lighthouse from the SDL in <%= sdl_folder %>/. The
4
4
  # Dataloader batches relationship loads (@hasMany, @belongsTo, ...) to avoid N+1.
5
+ # base_types names the base classes: every class a Query, Mutation or Subscription
6
+ # field resolves to must inherit from its kind's, and a Subscription field with no
7
+ # class of its own resolves with the subscription one.
5
8
  <%= schema_name %> = Lighthouse::GraphQL::RbLightHouse.get_schema(
6
9
  options: {
7
- using: { GraphQL::Dataloader => {} }
8
- # base_types: { object: Types::BaseObject } # your graphql-ruby base classes
10
+ using: { GraphQL::Dataloader => {} },
11
+ base_types: {
12
+ resolver: <%= base_class_constant(:queries) %>,
13
+ mutation: <%= base_class_constant(:mutations) %>,
14
+ subscription: <%= base_class_constant(:subscriptions) %>
15
+ # object: Types::BaseObject, ... # graphql-ruby's base classes of the SDL's types
16
+ }
9
17
  }
10
18
  )
19
+
20
+ # Refuse expensive queries before they run (see the gem's docs/security.md):
21
+ # <%= schema_name %>.max_complexity(300)
22
+ # <%= schema_name %>.max_depth(12)
@@ -2,10 +2,11 @@
2
2
 
3
3
  require_relative 'class_generator'
4
4
 
5
- # `rails generate lighthouse:mutation PublishPost` — a GraphQL::Schema::Mutation
6
- # that `Mutation.publishPost` resolves to by convention.
5
+ # `rails generate lighthouse:mutation PublishPost` — the class (a
6
+ # Mutations::BaseMutation, so a GraphQL::Schema::Mutation) that `Mutation.publishPost`
7
+ # resolves to by convention.
7
8
  class Lighthouse::Generators::MutationGenerator < Lighthouse::Generators::ClassGenerator
8
- desc 'Generate a mutation class (GraphQL::Schema::Mutation) in app/graphql/mutations.'
9
+ desc 'Generate the class of a Mutation field (a Mutations::BaseMutation) in app/graphql/mutations.'
9
10
 
10
11
  private
11
12
 
@@ -16,7 +17,7 @@ class Lighthouse::Generators::MutationGenerator < Lighthouse::Generators::ClassG
16
17
  # Resolves Mutation.#{class_basename.camelize(:lower)}. Keyword arguments are the field's
17
18
  # arguments in snake_case; `object` and `context` are available. Raise
18
19
  # GraphQL::ExecutionError for failures the client should see.
19
- class #{class_basename} < GraphQL::Schema::Mutation
20
+ class #{class_basename} < #{superclass_name}
20
21
  def resolve(**args)
21
22
  raise NotImplementedError
22
23
  end
@@ -2,10 +2,11 @@
2
2
 
3
3
  require_relative 'class_generator'
4
4
 
5
- # `rails generate lighthouse:query LatestPosts` — a GraphQL::Schema::Resolver
6
- # that `Query.latestPosts` resolves to by convention.
5
+ # `rails generate lighthouse:query LatestPosts` — the resolver (a
6
+ # Resolvers::BaseResolver, so a GraphQL::Schema::Resolver) that `Query.latestPosts`
7
+ # resolves to by convention.
7
8
  class Lighthouse::Generators::QueryGenerator < Lighthouse::Generators::ClassGenerator
8
- desc 'Generate a query class (GraphQL::Schema::Resolver) in app/graphql/queries.'
9
+ desc 'Generate the resolver of a Query field (a Resolvers::BaseResolver) in app/graphql/resolvers.'
9
10
 
10
11
  private
11
12
 
@@ -15,7 +16,7 @@ class Lighthouse::Generators::QueryGenerator < Lighthouse::Generators::ClassGene
15
16
  <<~RUBY
16
17
  # Resolves Query.#{class_basename.camelize(:lower)}. Keyword arguments are the field's
17
18
  # arguments in snake_case; `object` and `context` are available.
18
- class #{class_basename} < GraphQL::Schema::Resolver
19
+ class #{class_basename} < #{superclass_name}
19
20
  def resolve(**args)
20
21
  raise NotImplementedError
21
22
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'class_generator'
4
+
5
+ # `rails generate lighthouse:subscription PostUpdated` — the class (a
6
+ # Subscriptions::BaseSubscription, so a GraphQL::Schema::Subscription) that
7
+ # `Subscription.postUpdated` resolves to by convention. The base Lighthouse writes
8
+ # includes Lighthouse::GraphQL::Subscription, so the class gets PHP Lighthouse's
9
+ # authorize?/filter?/resolve_payload hooks; under a base without the concern, it
10
+ # gets graphql-ruby's authorized?/update instead.
11
+ class Lighthouse::Generators::SubscriptionGenerator < Lighthouse::Generators::ClassGenerator
12
+ desc 'Generate the class of a Subscription field (a Subscriptions::BaseSubscription) in app/graphql/subscriptions.'
13
+
14
+ private
15
+
16
+ def kind = :subscriptions
17
+
18
+ def body
19
+ lighthouse_base? ? lighthouse_body : plain_body
20
+ end
21
+
22
+ # Does the namespace's base class include Lighthouse's concern?
23
+ def lighthouse_base?
24
+ path = File.join(destination_root, base_class_path(kind, namespace_name))
25
+ File.exist?(path) && File.read(path).include?('Lighthouse::GraphQL::Subscription')
26
+ end
27
+
28
+ def plain_body
29
+ <<~RUBY
30
+ # Resolves Subscription.#{field_name}. Keyword arguments are the field's arguments in
31
+ # snake_case; `object` and `context` are available. `context` is the subscriber's:
32
+ # graphql-ruby stores it with the subscription and replays it for every update.
33
+ class #{class_basename} < #{superclass_name}
34
+ # Runs for the subscription request and again for every update. Raise a
35
+ # GraphQL::ExecutionError to refuse with a message (false refuses silently).
36
+ def authorized?(**args)
37
+ true
38
+ end
39
+
40
+ # The subscription request. Its return value answers the client right away;
41
+ # the default returns nothing (:no_response).
42
+ #
43
+ # def subscribe(**args)
44
+ # :no_response
45
+ # end
46
+
47
+ # Each update; `object` is what the mutation broadcast (@broadcast passes its
48
+ # result). The default returns `object`. Return NO_UPDATE to skip this
49
+ # subscriber, or another value to send that instead.
50
+ #
51
+ # def update(**args)
52
+ # return NO_UPDATE unless context[:current_user]
53
+ #
54
+ # object
55
+ # end
56
+
57
+ # What @broadcast triggers this subscription with, for the `root` a mutation
58
+ # returned. graphql-ruby updates only the subscribers whose arguments equal
59
+ # these, so return one value for every argument the field takes (`{}` for
60
+ # a field with none).
61
+ def self.trigger_arguments(root, context)
62
+ {}
63
+ end
64
+ end
65
+ RUBY
66
+ end
67
+
68
+ def lighthouse_body
69
+ <<~RUBY
70
+ # Resolves Subscription.#{field_name}. #{superclass_name} includes Lighthouse::GraphQL::Subscription,
71
+ # PHP Lighthouse's hooks: every subscriber of the field gets every broadcast, and
72
+ # `filter?` decides. All hooks are optional; `object` and `context` (the
73
+ # subscriber's) are available. graphql-ruby's own `authorized?` or `update`,
74
+ # defined here, win over them.
75
+ class #{class_basename} < #{superclass_name}
76
+ # May this client subscribe? Keyword arguments are the field's arguments.
77
+ def authorize?(**args)
78
+ true
79
+ end
80
+
81
+ # Should this subscriber receive the broadcast of `root`?
82
+ def filter?(root, **args)
83
+ true
84
+ end
85
+
86
+ # What the update resolves the field with.
87
+ def resolve_payload(root, **args)
88
+ root
89
+ end
90
+ end
91
+ RUBY
92
+ end
93
+
94
+ def field_name = class_basename.camelize(:lower)
95
+
96
+ def usage
97
+ "Subscription.#{field_name} resolves to #{full_class_name} — add the field to your SDL, " \
98
+ "and trigger it from a mutation with @broadcast(subscription: \"#{field_name}\") or `schema.subscriptions.trigger`."
99
+ end
100
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_job'
4
+
5
+ # Triggers a subscription from a queue (@broadcast with shouldQueue, or
6
+ # Config.queue_broadcasts). Loaded on first use — the activejob gem is
7
+ # optional — so it is autoloaded rather than listed in rb_lighthouse.rb.
8
+ # The schema is passed by name, and the root (an Active Record model, say)
9
+ # through ActiveJob's serialization. (A gem has no ApplicationJob to inherit from.)
10
+ class Lighthouse::GraphQL::BroadcastJob < ActiveJob::Base # rubocop:disable Rails/ApplicationJob
11
+ def perform(schema_name, subscription, trigger_arguments, root, scope = nil)
12
+ Lighthouse::GraphQL::Broadcasting.trigger(schema_name.constantize, subscription, trigger_arguments, root, scope)
13
+ end
14
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ # What @broadcast does once a mutation has resolved: trigger the subscription
4
+ # field on the schema's subscriptions backend (`schema.subscriptions.trigger`),
5
+ # inline or, when queued, in a Lighthouse::GraphQL::BroadcastJob. Also checks,
6
+ # when the schema is built, that every @broadcast names a Subscription field.
7
+ module Lighthouse::GraphQL::Broadcasting
8
+ module_function
9
+
10
+ # Broadcast `root` to the subscribers of the field `name`.
11
+ def broadcast(schema, name, root, context, should_queue: nil)
12
+ return unless backend?(schema, name)
13
+
14
+ klass = subscription_class(schema, name)
15
+ return unless triggerable?(schema, name, klass)
16
+
17
+ arguments = klass.respond_to?(:trigger_arguments) ? klass.trigger_arguments(root, context) : {}
18
+ scope = klass.respond_to?(:trigger_scope) ? klass.trigger_scope(root, context) : nil
19
+
20
+ if should_queue.nil? ? Lighthouse::Config.queue_broadcasts : should_queue
21
+ enqueue(schema, name, arguments, root, scope)
22
+ else
23
+ trigger(schema, name, arguments, root, scope)
24
+ end
25
+ end
26
+
27
+ # Can a trigger of `klass` reach anybody? graphql-ruby delivers to the
28
+ # subscribers whose arguments equal the trigger's. A class with exact-argument
29
+ # topics (a plain GraphQL::Schema::Subscription, or topic_by :arguments) that
30
+ # leaves `trigger_arguments` at the default `{}`, on a field with a required
31
+ # argument, therefore never reaches a single subscriber: each of them passed
32
+ # that argument. That is certain, so it is reported through Config.on_error
33
+ # (raised in a test environment, logged otherwise) and nothing is triggered.
34
+ # Arguments that are all optional are not reported: argument-less subscribers
35
+ # do match `{}`, which can be exactly what the class wants.
36
+ def triggerable?(schema, name, klass)
37
+ field = subscription_field(schema, name)
38
+ required = field ? required_arguments(field) : []
39
+ return true if required.empty? || trigger_arguments_defined?(klass)
40
+
41
+ Lighthouse::Config.handle_error(
42
+ Lighthouse::DefinitionError.new(
43
+ "@broadcast(subscription: #{name.inspect}) can reach nobody: #{klass} matches subscribers by exact arguments, " \
44
+ "but #{field.owner.graphql_name}.#{field.name} requires #{required.map { |arg| arg.graphql_name.inspect }.join(', ')} " \
45
+ 'and the class defines no `self.trigger_arguments(root, context)` saying what to trigger with. ' \
46
+ "Define it, e.g. `def self.trigger_arguments(root, _context) = { #{required.first.graphql_name.underscore}: root.id.to_s }`, " \
47
+ 'or include Lighthouse::GraphQL::Subscription for one topic per field, filtered by `filter?`.'
48
+ )
49
+ )
50
+ false
51
+ end
52
+
53
+ # Does a trigger of `klass` need its own trigger_arguments to match anyone?
54
+ # Not for the field-wide topic of a class including Lighthouse::GraphQL::Subscription.
55
+ def trigger_arguments_defined?(klass)
56
+ return true if klass < Lighthouse::GraphQL::Subscription && klass.topic_by == :field
57
+
58
+ klass.respond_to?(:trigger_arguments) && klass.method(:trigger_arguments).owner != Lighthouse::GraphQL::Subscription::ClassMethods
59
+ end
60
+
61
+ def required_arguments(field)
62
+ field.arguments.values.select { |arg| arg.type.non_null? && !arg.default_value? }
63
+ end
64
+
65
+ # Trigger the subscription now. Misuse (arguments the field does not take, a
66
+ # missing scope) goes through Config.on_error.
67
+ def trigger(schema, name, arguments, root, scope = nil)
68
+ schema.subscriptions.trigger(name, arguments, root, scope: scope)
69
+ rescue ::GraphQL::Subscriptions::InvalidTriggerError, ::GraphQL::Subscriptions::SubscriptionScopeMissingError => e
70
+ Lighthouse::Config.handle_error(e)
71
+ end
72
+
73
+ def enqueue(schema, name, arguments, root, scope)
74
+ if schema.name.nil?
75
+ raise Lighthouse::DefinitionError,
76
+ 'A queued @broadcast reaches its schema by name, but this schema is anonymous: assign it to a constant ' \
77
+ '(AppSchema = Lighthouse::GraphQL::RbLightHouse.get_schema).'
78
+ end
79
+
80
+ job = broadcast_job
81
+ queue = Lighthouse::Config.broadcast_queue
82
+ (queue ? job.set(queue: queue) : job).perform_later(schema.name, name, arguments, root, scope)
83
+ end
84
+
85
+ def broadcast_job
86
+ Lighthouse::GraphQL::BroadcastJob
87
+ rescue LoadError
88
+ raise Lighthouse::DefinitionError, 'Queueing @broadcast needs ActiveJob: add the activejob gem, or set shouldQueue: false.'
89
+ end
90
+
91
+ # Is there a backend to trigger? Reported through Config.on_error when not.
92
+ def backend?(schema, name)
93
+ return true if schema.subscriptions
94
+
95
+ Lighthouse::Config.handle_error(
96
+ Lighthouse::DefinitionError.new(
97
+ "@broadcast(subscription: #{name.inspect}) has nowhere to broadcast: #{schema.name || 'the schema'} has no subscriptions backend. " \
98
+ 'Install one, e.g. `use GraphQL::Subscriptions::ActionCableSubscriptions`, or pass ' \
99
+ '`options: { using: { GraphQL::Subscriptions::ActionCableSubscriptions => {} } }` to get_schema.'
100
+ )
101
+ )
102
+ false
103
+ end
104
+
105
+ # The Subscription field called `name` (as written in the SDL or in
106
+ # snake_case), or nil.
107
+ def subscription_field(schema, name)
108
+ fields = schema.subscription&.fields || {}
109
+ fields[name.to_s] || fields[name.to_s.camelize(:lower)]
110
+ end
111
+
112
+ # The class the field stands for (see SubscriptionDirectiveResolver).
113
+ def subscription_class(schema, name)
114
+ subscription_field(schema, name)&.resolver || Lighthouse::GraphQL::Subscription::Default
115
+ end
116
+
117
+ # Every @broadcast in the schema must name a field of its Subscription type.
118
+ def validate!(schema)
119
+ schema.types.each_value do |type|
120
+ next unless type.kind.object?
121
+
122
+ type.fields.each_value do |field_def|
123
+ broadcasts(field_def).each do |directive|
124
+ name = Lighthouse::Support::DirectiveArgs.literal(directive, 'subscription')
125
+ next if subscription_field(schema, name)
126
+
127
+ raise Lighthouse::DefinitionError, unknown_subscription(schema, type, field_def, name)
128
+ end
129
+ end
130
+ end
131
+ end
132
+
133
+ def broadcasts(field_def)
134
+ (field_def.ast_node&.directives || []).select { |directive| directive.name == 'broadcast' }
135
+ end
136
+
137
+ def unknown_subscription(schema, type, field_def, name)
138
+ where = "@broadcast on #{type.graphql_name}.#{field_def.name}"
139
+ return "#{where}: the schema has no Subscription type, so there is no subscription #{name.inspect}." unless schema.subscription
140
+
141
+ "#{where}: #{schema.subscription.graphql_name} has no field #{name.inspect}."
142
+ end
143
+ end
@@ -18,8 +18,11 @@
18
18
  # read the result with CacheControl.header(result) and set it on the
19
19
  # response (see `apply`).
20
20
  module Lighthouse::GraphQL::CacheControl
21
- DEFINITION = 'directive @cacheControl(maxAge: Int! = 0, scope: CacheControlScope! = PUBLIC, ' \
22
- 'inheritMaxAge: Boolean! = false) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION'
21
+ # Arguments with a default are nullable, not `Type! = default` as in PHP: graphql-ruby
22
+ # 2.5 does not fill in a directive argument's default, so a non-null one would be
23
+ # refused wherever it is left out. Left out or null, it takes its default.
24
+ DEFINITION = 'directive @cacheControl(maxAge: Int = 0, scope: CacheControlScope = PUBLIC, ' \
25
+ 'inheritMaxAge: Boolean = false) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION'
23
26
 
24
27
  TYPE_DEFINITIONS = {
25
28
  'CacheControlScope' => <<~GRAPHQL.chomp
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @complexity — what a field costs in a query's complexity score, the score
4
+ # graphql-ruby checks against the schema's `max_complexity` before it runs a
5
+ # query (GraphQL::Analysis::MaxQueryComplexity), as PHP Lighthouse does with
6
+ # webonyx's QueryComplexity rule.
7
+ #
8
+ # Lighthouse scores nothing itself: it gives fields their cost through
9
+ # graphql-ruby's `Field#complexity_for` hook, and graphql-ruby's analyzers add
10
+ # the costs up. A field costs:
11
+ #
12
+ # - With a bare @complexity, and on every paginated field (@paginate, and the
13
+ # relationship directives given `type:`), 1 plus its selection's cost once per
14
+ # item asked for (`first`, which defaults to the page size).
15
+ # - With @complexity(resolver: "Name"), the `complexity` the analyzer class
16
+ # declares when it includes Lighthouse::GraphQL::ComplexityAnalyzer; else as
17
+ # with a bare @complexity.
18
+ # - When a GraphQL::Schema::Resolver class resolves it (by convention, as
19
+ # `Resolvers::Search` resolves `Query.search`, or through @field), the
20
+ # `complexity` that class declares, as graphql-ruby gives `field ..., resolver:`.
21
+ # - Otherwise graphql-ruby's default: 1 plus its selection's cost.
22
+ #
23
+ # `resolver:` names a GraphQL::Analysis::Analyzer subclass, looked up in
24
+ # Config.namespaces[:analyzers] (`Analyzers::Name`). Lighthouse installs it on
25
+ # the schema, as graphql-ruby's `query_analyzer Analyzers::Name` would, so it
26
+ # runs on every query.
27
+ #
28
+ # Analyzer scores each query for BuildExtensionsResponse#query_complexity while
29
+ # something listens to that event.
30
+ module Lighthouse::GraphQL::Complexity
31
+ DEFINITION = 'directive @complexity(resolver: String) on FIELD_DEFINITION'
32
+
33
+ CONTEXT_KEY = :lighthouse_query_complexity
34
+
35
+ module_function
36
+
37
+ # Give the schema's fields their cost and install the analyzers @complexity
38
+ # names (see above). `root_kinds` maps the root types' names to :queries /
39
+ # :mutations / :subscriptions.
40
+ def apply(schema, root_kinds = {})
41
+ schema.types.each_value do |type|
42
+ next unless type.kind.object? || type.kind.interface?
43
+
44
+ type.fields.each_value do |field|
45
+ calculator = calculator_for(schema, type, field, root_kinds[type.graphql_name])
46
+ install(field, calculator) if calculator
47
+ end
48
+ end
49
+ schema
50
+ end
51
+
52
+ # A query's score, once Analyzer has run on it; nil otherwise.
53
+ def query_complexity(query)
54
+ query.context[CONTEXT_KEY]
55
+ end
56
+
57
+ # graphql-ruby asks the field for its cost through `complexity_for`, which wins
58
+ # over a number or proc in `complexity` and over its own scaling of connections.
59
+ def install(field, calculator)
60
+ field.define_singleton_method(:complexity_for) do |child_complexity:, query:, lookahead:|
61
+ calculator.call(query.context, lookahead.arguments, child_complexity)
62
+ end
63
+ end
64
+
65
+ # A callable (context, args, child_complexity) -> cost, or nil to leave the
66
+ # field to graphql-ruby.
67
+ def calculator_for(schema, type, field, kind)
68
+ return unless field.ast_node
69
+
70
+ directives = field.ast_node.directives || []
71
+ directive = directives.find { |d| d.name == 'complexity' }
72
+ return directive_calculator(schema, directive, "#{type.graphql_name}.#{field.name}") if directive
73
+ return method(:per_item) if paginated?(directives)
74
+
75
+ resolver_class_calculator(field, directives, kind)
76
+ end
77
+
78
+ def directive_calculator(schema, directive, path)
79
+ name = Lighthouse::Support::DirectiveArgs.literal(directive, 'resolver')
80
+ return method(:per_item) if name.nil? || name.to_s.strip.empty?
81
+
82
+ analyzer = analyzer_class(name.to_s, path)
83
+ schema.query_analyzer(analyzer) unless schema.query_analyzers.include?(analyzer)
84
+ return method(:per_item) unless analyzer.include?(Lighthouse::GraphQL::ComplexityAnalyzer)
85
+
86
+ lambda do |context, args, child_complexity|
87
+ cost = analyzer.complexity
88
+ cost.nil? ? per_item(context, args, child_complexity) : own_cost(cost, context, args, child_complexity)
89
+ end
90
+ end
91
+
92
+ def analyzer_class(name, path)
93
+ namespaces = Lighthouse::Config.namespaces[:analyzers]
94
+ klass = Lighthouse::Support::ModelResolver.constantize(name, namespaces: namespaces)
95
+ unless klass
96
+ raise Lighthouse::DefinitionError,
97
+ "@complexity on #{path}: no analyzer #{name} (looked up in #{Array(namespaces).join(', ')})"
98
+ end
99
+ return klass if klass.is_a?(Class) && klass < ::GraphQL::Analysis::Analyzer
100
+
101
+ raise Lighthouse::DefinitionError, "@complexity on #{path}: #{klass} must inherit from GraphQL::Analysis::Analyzer"
102
+ end
103
+
104
+ # The field and its selection once per item asked for: PHP Lighthouse's
105
+ # default, and what a page of `first` items costs.
106
+ def per_item(_context, args, child_complexity)
107
+ 1 + (child_complexity * (args[:first] || 1))
108
+ end
109
+
110
+ # graphql-ruby's default cost: the field, plus its selection.
111
+ def default_cost(child_complexity)
112
+ 1 + child_complexity
113
+ end
114
+
115
+ # A cost as graphql-ruby's Field#complexity takes it: a number, added to the
116
+ # selection's, or a proc (context, args, child_complexity).
117
+ def own_cost(cost, context, args, child_complexity)
118
+ cost.respond_to?(:call) ? cost.call(context, args, child_complexity) : cost + child_complexity
119
+ end
120
+
121
+ def paginated?(directives)
122
+ relations = Lighthouse::GraphQL::Directives::PaginationManipulator::RELATION_DIRECTIVES
123
+ directives.any? do |d|
124
+ d.name == 'paginate' || (relations.include?(d.name) && Lighthouse::Support::DirectiveArgs.literal(d, 'type'))
125
+ end
126
+ end
127
+
128
+ # The `complexity` of the GraphQL::Schema::Resolver class that resolves the
129
+ # field, by convention or through @field; nil when no such class can. The
130
+ # class is looked up when a query is scored, so code reloading works.
131
+ def resolver_class_calculator(field, directives, kind)
132
+ lookup = resolver_class_lookup(field, directives, kind)
133
+ return unless lookup
134
+
135
+ lambda do |context, args, child_complexity|
136
+ klass = lookup.call
137
+ next default_cost(child_complexity) unless Lighthouse::GraphQL::ResolverDispatch.graphql_resolver?(klass)
138
+
139
+ own_cost(klass.complexity, context, args, child_complexity)
140
+ end
141
+ end
142
+
143
+ def resolver_class_lookup(field, directives, kind)
144
+ directive = directives.find { |d| d.name == 'field' }
145
+ return field_class_lookup(directive, kind) if directive
146
+ return unless %i[queries mutations].include?(kind) && !Lighthouse::GraphQL::FieldResolverStack.resolving_directive?(directives)
147
+
148
+ class_name = Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver.class_name_for(field)
149
+ -> { Lighthouse::Support::ModelResolver.constantize(class_name, namespaces: Lighthouse::Config.namespaces[kind], fallback: false) }
150
+ end
151
+
152
+ # The class @field names, looked up as @field looks it up.
153
+ def field_class_lookup(directive, kind)
154
+ class_name = %w[resolver class].filter_map { |name| Lighthouse::Support::DirectiveArgs.literal(directive, name) }.first
155
+ return unless class_name
156
+
157
+ namespaces = kind ? Lighthouse::Config.namespaces[kind] : Lighthouse::Config.resolver_namespaces
158
+ -> { Lighthouse::Support::ModelResolver.constantize(class_name, namespaces: namespaces) }
159
+ end
160
+
161
+ # Scores each query as graphql-ruby's MaxQueryComplexity scores it against
162
+ # `max_complexity`, for BuildExtensionsResponse#query_complexity. Runs only
163
+ # while something listens to that event.
164
+ class Analyzer < ::GraphQL::Analysis::QueryComplexity
165
+ def analyze?
166
+ Lighthouse::Events.listening?(Lighthouse::Events::BuildExtensionsResponse)
167
+ end
168
+
169
+ def result
170
+ query.context[CONTEXT_KEY] = max_possible_complexity
171
+ nil
172
+ end
173
+ end
174
+ end