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
|
@@ -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
|
-
|
|
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
|
-
|
|
30
|
-
end
|
|
12
|
+
lighthouse_schema <%= schema_name %>
|
|
31
13
|
|
|
32
14
|
private
|
|
33
15
|
|
|
34
|
-
#
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
16
|
-
# to Mutations::PublishPost (a
|
|
17
|
-
# come from :builders and @validator contracts from :validators.
|
|
18
|
-
#
|
|
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: ['
|
|
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
|
-
|
|
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
|
|
6
|
-
# that `Mutation.publishPost`
|
|
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
|
|
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} <
|
|
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
|
|
6
|
-
# that `Query.latestPosts`
|
|
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
|
|
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} <
|
|
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
|
-
|
|
22
|
-
|
|
21
|
+
# Arguments with a default are nullable, not `Type! = default` as in PHP: graphql-ruby
|
|
22
|
+
# 2.5 does not fill in a directive argument's default, so a non-null one would be
|
|
23
|
+
# refused wherever it is left out. Left out or null, it takes its default.
|
|
24
|
+
DEFINITION = 'directive @cacheControl(maxAge: Int = 0, scope: CacheControlScope = PUBLIC, ' \
|
|
25
|
+
'inheritMaxAge: Boolean = false) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION'
|
|
23
26
|
|
|
24
27
|
TYPE_DEFINITIONS = {
|
|
25
28
|
'CacheControlScope' => <<~GRAPHQL.chomp
|
|
@@ -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
|