lighthouse-graphql 0.3.0 → 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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/README.md +11 -3
  4. data/docs/best-practices.md +16 -6
  5. data/docs/configuration.md +7 -6
  6. data/docs/directives.md +31 -6
  7. data/docs/events.md +3 -1
  8. data/docs/federation.md +3 -2
  9. data/docs/getting-started.md +52 -14
  10. data/docs/motivation.md +3 -2
  11. data/docs/resolvers.md +93 -19
  12. data/docs/security.md +210 -0
  13. data/docs/subscriptions.md +60 -37
  14. data/lib/generators/lighthouse/base_classes.rb +87 -0
  15. data/lib/generators/lighthouse/class_generator.rb +19 -8
  16. data/lib/generators/lighthouse/install/install_generator.rb +23 -6
  17. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +7 -6
  18. data/lib/generators/lighthouse/install/templates/schema.rb.tt +14 -2
  19. data/lib/generators/lighthouse/mutation_generator.rb +5 -4
  20. data/lib/generators/lighthouse/query_generator.rb +5 -4
  21. data/lib/generators/lighthouse/subscription_generator.rb +21 -16
  22. data/lib/lighthouse/complexity.rb +174 -0
  23. data/lib/lighthouse/complexity_analyzer.rb +56 -0
  24. data/lib/lighthouse/config.rb +4 -3
  25. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +14 -4
  26. data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +1 -1
  27. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +12 -4
  28. data/lib/lighthouse/directives/registry.rb +1 -0
  29. data/lib/lighthouse/events/execution_trace.rb +2 -1
  30. data/lib/lighthouse/events/lifecycle.rb +3 -1
  31. data/lib/lighthouse/field_resolver_stack.rb +8 -7
  32. data/lib/lighthouse/graphql/version.rb +1 -1
  33. data/lib/lighthouse/rb_lighthouse.rb +3 -0
  34. data/lib/lighthouse/resolver_bases.rb +61 -0
  35. data/lib/lighthouse/schema_factory.rb +0 -1
  36. data/lib/lighthouse/schema_generator.rb +15 -7
  37. data/lib/lighthouse/sdl_loader.rb +0 -2
  38. data/lib/lighthouse/support/model_resolver.rb +1 -1
  39. data/sig/lighthouse/graphql.rbs +29 -0
  40. metadata +6 -1
@@ -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
@@ -2,22 +2,27 @@
2
2
 
3
3
  require_relative 'class_generator'
4
4
 
5
- # `rails generate lighthouse:subscription PostUpdated` — a
6
- # GraphQL::Schema::Subscription that `Subscription.postUpdated` resolves to by
7
- # convention. With `--lighthouse`, the class also includes
8
- # Lighthouse::GraphQL::Subscription (PHP Lighthouse's authorize?/filter?/resolve_payload hooks).
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.
9
11
  class Lighthouse::Generators::SubscriptionGenerator < Lighthouse::Generators::ClassGenerator
10
- desc 'Generate a subscription class (GraphQL::Schema::Subscription) in app/graphql/subscriptions.'
11
-
12
- class_option :lighthouse, type: :boolean, default: false,
13
- desc: "Include Lighthouse::GraphQL::Subscription: PHP Lighthouse's authorize?/filter?/resolve_payload hooks"
12
+ desc 'Generate the class of a Subscription field (a Subscriptions::BaseSubscription) in app/graphql/subscriptions.'
14
13
 
15
14
  private
16
15
 
17
16
  def kind = :subscriptions
18
17
 
19
18
  def body
20
- options[:lighthouse] ? lighthouse_body : plain_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')
21
26
  end
22
27
 
23
28
  def plain_body
@@ -25,7 +30,7 @@ class Lighthouse::Generators::SubscriptionGenerator < Lighthouse::Generators::Cl
25
30
  # Resolves Subscription.#{field_name}. Keyword arguments are the field's arguments in
26
31
  # snake_case; `object` and `context` are available. `context` is the subscriber's:
27
32
  # graphql-ruby stores it with the subscription and replays it for every update.
28
- class #{class_basename} < GraphQL::Schema::Subscription
33
+ class #{class_basename} < #{superclass_name}
29
34
  # Runs for the subscription request and again for every update. Raise a
30
35
  # GraphQL::ExecutionError to refuse with a message (false refuses silently).
31
36
  def authorized?(**args)
@@ -62,12 +67,12 @@ class Lighthouse::Generators::SubscriptionGenerator < Lighthouse::Generators::Cl
62
67
 
63
68
  def lighthouse_body
64
69
  <<~RUBY
65
- # Resolves Subscription.#{field_name}. A graphql-ruby subscription with PHP Lighthouse's
66
- # hooks: every subscriber of the field gets every broadcast, and `filter?` decides.
67
- # All hooks are optional; `object` and `context` (the subscriber's) are available.
68
- class #{class_basename} < GraphQL::Schema::Subscription
69
- include Lighthouse::GraphQL::Subscription
70
-
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}
71
76
  # May this client subscribe? Keyword arguments are the field's arguments.
72
77
  def authorize?(**args)
73
78
  true
@@ -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
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ # A concern for a graphql-ruby analyzer that @complexity(resolver:) names. Its
6
+ # one method, `complexity`, gives the fields naming the class their cost, as
7
+ # graphql-ruby's `complexity` gives a field or a GraphQL::Schema::Resolver one:
8
+ #
9
+ # class Analyzers::TagPostsComplexity < GraphQL::Analysis::QueryComplexity
10
+ # include Lighthouse::GraphQL::ComplexityAnalyzer
11
+ #
12
+ # complexity ->(_context, _args, child_complexity) { 1 + (child_complexity * 25) }
13
+ # end
14
+ #
15
+ # A number is added to the cost of the field's selection; a proc is called with
16
+ # (context, args, child_complexity) and returns the whole cost. Without
17
+ # `complexity`, the field costs what a bare @complexity gives.
18
+ #
19
+ # The class stays an analyzer, installed on the schema like any class @complexity
20
+ # names: a `result` of its own runs on every query, in graphql-ruby's analysis.
21
+ # Without one it only gives costs, and graphql-ruby skips it.
22
+ module Lighthouse::GraphQL::ComplexityAnalyzer
23
+ extend ActiveSupport::Concern
24
+
25
+ class_methods do
26
+ # Set the cost (a number or a proc taking context, args and child_complexity),
27
+ # or read it: the class's own, else its superclass's, else nil.
28
+ def complexity(new_complexity = nil)
29
+ return inherited_complexity if new_complexity.nil?
30
+
31
+ unless new_complexity.is_a?(Numeric) || (new_complexity.is_a?(Proc) && new_complexity.parameters.size == 3)
32
+ raise ArgumentError, "#{name}.complexity takes a number or a proc of (context, args, child_complexity), " \
33
+ "got #{new_complexity.inspect}"
34
+ end
35
+
36
+ @complexity = new_complexity
37
+ end
38
+
39
+ private
40
+
41
+ def inherited_complexity
42
+ return @complexity if defined?(@complexity)
43
+
44
+ superclass.respond_to?(:complexity) ? superclass.complexity : nil
45
+ end
46
+ end
47
+
48
+ # Run in graphql-ruby's analysis only when the class has a `result` of its own.
49
+ def analyze?
50
+ self.class.instance_method(:result).owner != Lighthouse::GraphQL::ComplexityAnalyzer && super
51
+ end
52
+
53
+ def result
54
+ nil
55
+ end
56
+ end
@@ -12,14 +12,15 @@ module Lighthouse
12
12
  # Where each kind of class a schema refers to by name is looked up. Query and
13
13
  # mutation fields resolve to GraphQL::Schema::Resolver / Mutation classes,
14
14
  # builders compose onto a relation, validators check arguments: distinct
15
- # contracts, so distinct namespaces (app/graphql/queries, .../mutations, ...).
15
+ # contracts, so distinct namespaces (app/graphql/resolvers, .../mutations, ...).
16
16
  DEFAULT_NAMESPACES = {
17
- queries: ['Queries'],
17
+ queries: ['Resolvers'],
18
18
  mutations: ['Mutations'],
19
19
  builders: ['Builders'],
20
20
  validators: ['Validators'],
21
21
  events: ['Events'],
22
- subscriptions: ['Subscriptions']
22
+ subscriptions: ['Subscriptions'],
23
+ analyzers: ['Analyzers']
23
24
  }.freeze
24
25
 
25
26
  # Every setting reset! forgets, so the next read falls back to its default.
@@ -4,14 +4,17 @@ require_relative 'base'
4
4
 
5
5
  # Resolves a Query or Mutation field that has no resolving directive by
6
6
  # convention, as PHP Lighthouse does: `Query.latestPosts` resolves to
7
- # `Queries::LatestPosts` (a GraphQL::Schema::Resolver), `Mutation.publishPost`
8
- # to `Mutations::PublishPost` (a GraphQL::Schema::Mutation), looked up in
7
+ # `Resolvers::LatestPosts` (a GraphQL::Schema::Resolver, by graphql-ruby's
8
+ # convention a Resolvers::BaseResolver), `Mutation.publishPost` to
9
+ # `Mutations::PublishPost` (a GraphQL::Schema::Mutation), looked up in
9
10
  # Config.namespaces[:queries] / [:mutations] at call time so code reloading
10
11
  # keeps working. Not a directive: SchemaGenerator installs it.
11
12
  class Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
12
- def initialize(field_def, type_def, kind)
13
+ # `base`: the class the schema's base_types require the class to inherit from.
14
+ def initialize(field_def, type_def, kind, base = nil)
13
15
  super(field_def, type_def, nil)
14
16
  @kind = kind
17
+ @base = base
15
18
  end
16
19
 
17
20
  def define_resolver(target_type_class)
@@ -31,6 +34,12 @@ class Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver < Lighthouse::
31
34
  ROOT_KINDS[type.respond_to?(:graphql_name) ? type.graphql_name : type.to_s]
32
35
  end
33
36
 
37
+ # The class a root field resolves to, without its namespace: `LatestPosts`
38
+ # for `latestPosts`.
39
+ def self.class_name_for(field_def)
40
+ field_def.name.to_s.camelize
41
+ end
42
+
34
43
  private
35
44
 
36
45
  def resolve_by_convention(inner, obj, args, ctx)
@@ -43,6 +52,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver < Lighthouse::
43
52
  "#{klass} resolves #{location}, so it must inherit from GraphQL::Schema::Resolver " \
44
53
  '(or GraphQL::Schema::Mutation).'
45
54
  end
55
+ Lighthouse::GraphQL::ResolverBases.check!(klass, @base, location)
46
56
 
47
57
  Lighthouse::GraphQL::ResolverDispatch.call(klass, @field_def, obj, args, ctx)
48
58
  end
@@ -61,7 +71,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver < Lighthouse::
61
71
  end
62
72
 
63
73
  def class_name
64
- @class_name ||= @field_def.name.to_s.camelize
74
+ @class_name ||= self.class.class_name_for(@field_def)
65
75
  end
66
76
 
67
77
  def location
@@ -19,7 +19,7 @@ require_relative 'base'
19
19
  # carried the directive.
20
20
  #
21
21
  # The conversion is a wrapper around the field's resolver (outermost, order
22
- # 6), so every kind of resolver — directives, @field, Queries::/Mutations::
22
+ # 6), so every kind of resolver — directives, @field, Resolvers::/Mutations::
23
23
  # classes, the default reader — and @validator see the converted arguments,
24
24
  # and other fields sharing an input type are unaffected. SchemaGenerator
25
25
  # installs it too, without the directive on the field, when an argument or
@@ -8,8 +8,9 @@ require_relative 'base'
8
8
  # Config.namespaces[:subscriptions] at call time so code reloading keeps
9
9
  # working. The class is a GraphQL::Schema::Subscription, which may include
10
10
  # Lighthouse::GraphQL::Subscription for PHP Lighthouse's hooks. A field
11
- # with no class still works: it is a Lighthouse::GraphQL::Subscription::Default
12
- # whose update is the broadcast root, which is all `@broadcast` needs.
11
+ # with no class still works: it resolves with the schema's base_types[:subscription]
12
+ # when given, else with Lighthouse::GraphQL::Subscription::Default, whose update
13
+ # is the broadcast root, which is all `@broadcast` needs.
13
14
  #
14
15
  # graphql-ruby registers a subscription through the class its field was
15
16
  # built from, which a field built from SDL has not. The field is therefore
@@ -38,20 +39,27 @@ class Lighthouse::GraphQL::DirectiveResolvers::SubscriptionDirectiveResolver < L
38
39
  attr_reader :field_def
39
40
 
40
41
  # The class this field stands for: the one @subscription names, else the
41
- # conventional one, else the default.
42
+ # conventional one, else the schema's base subscription, else the default.
42
43
  def subscription_class
43
44
  klass = find_class
44
- return Lighthouse::GraphQL::Subscription::Default unless klass
45
+ return base || Lighthouse::GraphQL::Subscription::Default unless klass
45
46
 
46
47
  unless klass.is_a?(Class) && klass < ::GraphQL::Schema::Subscription
47
48
  raise Lighthouse::DefinitionError, "#{klass} resolves #{location}, so it must inherit from GraphQL::Schema::Subscription."
48
49
  end
49
50
 
51
+ Lighthouse::GraphQL::ResolverBases.check!(klass, base, location)
52
+
50
53
  klass
51
54
  end
52
55
 
53
56
  private
54
57
 
58
+ # The schema's base_types[:subscription], or nil.
59
+ def base
60
+ (@opts[:bases] || {})[:subscriptions]
61
+ end
62
+
55
63
  def find_class
56
64
  namespaces = Lighthouse::Config.namespaces[:subscriptions]
57
65
  named = directive_arg('class') if @directive_node
@@ -218,6 +218,7 @@ class Lighthouse::DirectiveRegistry
218
218
  register_definition('cacheControl', Lighthouse::GraphQL::CacheControl::DEFINITION)
219
219
  Lighthouse::GraphQL::CacheControl::TYPE_DEFINITIONS.each { |name, sdl| register_type_definition(name, sdl) }
220
220
  register_definition('feature', Lighthouse::GraphQL::FeatureDirective::DEFINITION)
221
+ register_definition('complexity', Lighthouse::GraphQL::Complexity::DEFINITION)
221
222
  Lighthouse::GraphQL::FeatureDirective::TYPE_DEFINITIONS.each { |name, sdl| register_type_definition(name, sdl) }
222
223
  Lighthouse::GraphQL::Directives::WhereConditionsDirective::DEFINITIONS.each do |name, definition|
223
224
  register_definition(name, definition)
@@ -33,7 +33,8 @@ module Lighthouse::Events::ExecutionTrace
33
33
  result = query.result_values
34
34
  return unless result.is_a?(Hash)
35
35
 
36
- merge_extensions(result, Lighthouse::Events.dispatch(Lighthouse::Events::BuildExtensionsResponse.new(result, nil)))
36
+ complexity = Lighthouse::GraphQL::Complexity.query_complexity(query)
37
+ merge_extensions(result, Lighthouse::Events.dispatch(Lighthouse::Events::BuildExtensionsResponse.new(result, complexity)))
37
38
  manipulate = Lighthouse::Events::ManipulateResult.new(result)
38
39
  Lighthouse::Events.dispatch(manipulate)
39
40
  result.replace(manipulate.result) unless manipulate.result.equal?(result)
@@ -71,7 +71,9 @@ module Lighthouse::Events
71
71
 
72
72
  # A query ran. A listener may return an Events::ExtensionsResponse (or an
73
73
  # Array of them), which is merged into the result's "extensions".
74
- # `query_complexity` is nil unless it was computed.
74
+ # `query_complexity` is the query's complexity score, as graphql-ruby checks it
75
+ # against max_complexity (see GraphQL::Complexity); nil for a query that
76
+ # failed validation, which is never scored.
75
77
  class BuildExtensionsResponse < Event
76
78
  attr_reader :result, :query_complexity
77
79
 
@@ -3,17 +3,18 @@
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)
18
19
  steps << empty_strings_step(type, field_def, opts) if empty_string_conversion?(field_def, directives)
19
20
 
@@ -38,13 +39,13 @@ module Lighthouse::GraphQL::FieldResolverStack
38
39
  directives.any? { |d| registry.resolution_class(d.name) && !registry.wrapper?(d.name) }
39
40
  end
40
41
 
41
- def convention_step(type, field_def, root_kind)
42
+ def convention_step(type, field_def, root_kind, bases = {})
42
43
  resolvers = Lighthouse::GraphQL::DirectiveResolvers
43
44
  resolver =
44
45
  if root_kind == :subscriptions
45
- resolvers::SubscriptionDirectiveResolver.new(field_def, type, nil, root_kind: root_kind)
46
+ resolvers::SubscriptionDirectiveResolver.new(field_def, type, nil, root_kind: root_kind, bases: bases)
46
47
  else
47
- resolvers::ConventionResolver.new(field_def, type, root_kind)
48
+ resolvers::ConventionResolver.new(field_def, type, root_kind, bases[root_kind])
48
49
  end
49
50
  [0, -> { resolver.define_resolver(type) }]
50
51
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lighthouse
4
4
  module GraphQL
5
- VERSION = '0.3.0'
5
+ VERSION = '0.4.0'
6
6
  end
7
7
  end
@@ -44,6 +44,7 @@ require_relative 'namespaces'
44
44
  events/lifecycle
45
45
  events/execution_trace
46
46
  events/schema_build
47
+ resolver_bases
47
48
  schema_generator
48
49
  schema_cache
49
50
  persisted_queries
@@ -55,6 +56,8 @@ require_relative 'namespaces'
55
56
  caching
56
57
  cache_control
57
58
  feature_directive
59
+ complexity
60
+ complexity_analyzer
58
61
  broadcasting
59
62
  subscription
60
63
  pagination
@@ -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