lighthouse-graphql 0.5.0 → 0.6.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +118 -1
  3. data/README.md +24 -16
  4. data/docs/caching.md +4 -3
  5. data/docs/configuration.md +1 -21
  6. data/docs/directives.md +14 -7
  7. data/docs/generators.md +41 -39
  8. data/docs/getting-started.md +88 -51
  9. data/docs/performance.md +2 -2
  10. data/docs/relationships.md +5 -4
  11. data/docs/relay.md +63 -0
  12. data/docs/resolvers.md +90 -37
  13. data/docs/security.md +64 -16
  14. data/docs/subscriptions.md +16 -9
  15. data/docs/types.md +16 -17
  16. data/docs/uploads.md +41 -14
  17. data/lib/generators/lighthouse/base_classes.rb +9 -14
  18. data/lib/generators/lighthouse/install/graphql_ruby_install.rb +113 -0
  19. data/lib/generators/lighthouse/install/install_generator.rb +112 -91
  20. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +3 -9
  21. data/lib/lighthouse/caching.rb +9 -3
  22. data/lib/lighthouse/config.rb +6 -23
  23. data/lib/lighthouse/controller.rb +28 -5
  24. data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +18 -18
  25. data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +1 -1
  26. data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +3 -1
  27. data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +85 -26
  28. data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +12 -6
  29. data/lib/lighthouse/directives/arguments/more_filters.rb +1 -1
  30. data/lib/lighthouse/field_resolver_stack.rb +9 -6
  31. data/lib/lighthouse/global_id.rb +29 -3
  32. data/lib/lighthouse/graphql/version.rb +1 -1
  33. data/lib/lighthouse/graphql_ruby_patches.rb +45 -0
  34. data/lib/lighthouse/rb_lighthouse.rb +1 -0
  35. data/lib/lighthouse/resolver_bases.rb +18 -4
  36. data/lib/lighthouse/resolver_dispatch.rb +16 -1
  37. data/lib/lighthouse/root_types.rb +117 -0
  38. data/lib/lighthouse/schema_generator.rb +4 -3
  39. data/lib/lighthouse/upload.rb +4 -2
  40. data/lib/lighthouse/upload_store.rb +39 -14
  41. data/sig/lighthouse.rbs +0 -10
  42. metadata +3 -4
  43. data/lib/generators/lighthouse/install/templates/schema.rb.tt +0 -29
  44. data/lib/generators/lighthouse/mutation_generator.rb +0 -31
  45. data/lib/generators/lighthouse/query_generator.rb +0 -30
@@ -1,17 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support/duration'
4
+ require 'active_support/notifications'
3
5
  require_relative 'base'
4
6
 
5
- # @throttle — rate-limit a field per client, as PHP Lighthouse's directive (and
6
- # Laravel's ThrottleRequests): at most `maxAttempts` calls every `decayMinutes`,
7
- # counted in Config.cache_store. A client is the signed-in user, else the
8
- # context's :remote_ip (Lighthouse::GraphQL::Controller sets it), else "guest".
9
- # Past the limit the field fails with "Rate limit for Query.x exceeded. Try
10
- # again later." Outermost: refused calls never reach @guard or the resolver.
7
+ # @throttle — Rails' `rate_limit` on one field: at most `maxAttempts` calls (its
8
+ # to:) every `decayMinutes` (its within:), counted as ActionController::RateLimiting
9
+ # counts them, with its options:
10
+ #
11
+ # by: who the client is, as rate_limit's by: a path in the context, as
12
+ # @inject's ("current_user.id"); the client's IP by default.
13
+ # with: a method of the controller to call past the limit, as rate_limit's
14
+ # with:. One that renders or redirects ends the request with its
15
+ # response, as a before_action that renders does; one that returns lets
16
+ # the call through. Without it, rate_limit's default: raise
17
+ # ActionController::TooManyRequests, which Rails answers with a 429
18
+ # (`head :too_many_requests` on a Rails without it, 7.2). Without Action
19
+ # Pack at all, the field fails: "Rate limit for Query.x exceeded. Try again later."
20
+ # name: as rate_limit's: the field by default (Query.x). Fields, and the
21
+ # controller's own `rate_limit`, of the same name count together.
22
+ # prefix: as rate_limit's scope: the controller's path by default.
23
+ #
24
+ # The count lives in the controller's cache_store (config.action_controller.cache_store,
25
+ # Rails.cache unless set), under rate_limit's key, and a refused call is instrumented
26
+ # as rate_limit's (rate_limit.action_controller). The controller concern puts the
27
+ # controller in the context (:lighthouse_controller); outside one, the count lives in
28
+ # Rails.cache, with no scope. Outermost: refused calls never reach @guard or the resolver.
11
29
  #
12
30
  # type Mutation {
13
31
  # login(email: String!, password: String!): User @throttle(maxAttempts: 5)
14
- # search(term: String!): [Post!]! @throttle(name: "search", maxAttempts: 30, decayMinutes: 0.5)
32
+ # search(term: String!): [Post!]! @throttle(maxAttempts: 30, decayMinutes: 0.5, by: "current_user.id")
15
33
  # }
16
34
  class Lighthouse::GraphQL::DirectiveResolvers::ThrottleDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
17
35
  def self.directive_name = 'throttle'
@@ -19,17 +37,21 @@ class Lighthouse::GraphQL::DirectiveResolvers::ThrottleDirectiveResolver < Light
19
37
  def self.definition
20
38
  <<~GRAPHQL.chomp
21
39
  """
22
- Sets rate limit to access the field. Does the same as ThrottleRequests Laravel Middleware.
40
+ Sets rate limit to access the field, as Rails' rate_limit does for an action.
23
41
  """
24
42
  directive @throttle(
25
- "A name shared by the fields that count together. Defaults to the field."
43
+ "As rate_limit's name: fields, and the controller's own rate_limit, of the same name count together. Defaults to the field."
26
44
  name: String
27
45
  "Maximum number of attempts in a specified time interval."
28
46
  maxAttempts: Int = 60
29
47
  "Time in minutes to reset attempts."
30
48
  decayMinutes: Float = 1.0
31
- "Prefix to distinguish several field groups."
49
+ "As rate_limit's scope: what the count is shared across. Defaults to the controller's path."
32
50
  prefix: String
51
+ "As rate_limit's by: who the client is, a path in the context (\\"current_user.id\\"). Defaults to the client's IP."
52
+ by: String
53
+ "As rate_limit's with: the controller's method to call past the limit. Defaults to rate_limit's: a 429."
54
+ with: String
33
55
  ) on FIELD_DEFINITION
34
56
  GRAPHQL
35
57
  end
@@ -38,22 +60,24 @@ class Lighthouse::GraphQL::DirectiveResolvers::ThrottleDirectiveResolver < Light
38
60
  me = self
39
61
  inner = capture_inner(target_type_class)
40
62
  location = "#{target_type_class.graphql_name}.#{@field_def.name}"
41
- bucket = "#{directive_arg('prefix')}#{directive_arg('name') || location}"
42
- max_attempts = directive_arg('maxAttempts') || 60
43
- decay = ((directive_arg('decayMinutes') || 1.0) * 60).ceil
63
+ limit = {
64
+ to: directive_arg('maxAttempts') || 60, within: ActiveSupport::Duration.build(((directive_arg('decayMinutes') || 1.0) * 60).ceil),
65
+ by: directive_arg('by'), with: directive_arg('with'), name: directive_arg('name') || location, scope: directive_arg('prefix')
66
+ }
44
67
 
45
68
  install(target_type_class, lambda do |obj, args, ctx|
46
- me.hit!(bucket, ctx, max_attempts, decay, location)
69
+ me.hit!(limit, ctx, location)
47
70
  inner.call(obj, args, ctx)
48
71
  end)
49
72
  end
50
73
 
51
- def hit!(bucket, ctx, max_attempts, decay, location)
52
- key = "lighthouse:throttle:#{bucket}:#{client(ctx)}"
53
- store = Lighthouse::GraphQL::Caching.store
54
- self.class.warn_null_store(location) if store.is_a?(::ActiveSupport::Cache::NullStore)
55
- attempts = store.increment(key, 1, expires_in: decay) || (store.write(key, 1, expires_in: decay) && 1)
56
- raise ::GraphQL::ExecutionError, "Rate limit for #{location} exceeded. Try again later." if attempts > max_attempts
74
+ # ActionController::RateLimiting#rate_limiting, for one call of the field.
75
+ def hit!(limit, ctx, location)
76
+ controller = ctx[:lighthouse_controller]
77
+ key = { scope: limit[:scope] || controller&.controller_path, name: limit[:name], by: client(limit, ctx) }
78
+ cache_key = ['rate-limit', *key.values].compact.join(':')
79
+ count = increment(store(controller), cache_key, limit[:within], location)
80
+ refuse(controller, limit, location, key.merge(count: count, cache_key: cache_key)) if count && count > limit[:to]
57
81
  end
58
82
 
59
83
  # A :null_store keeps no count, so the limit never applies: say so, once per field.
@@ -61,17 +85,52 @@ class Lighthouse::GraphQL::DirectiveResolvers::ThrottleDirectiveResolver < Light
61
85
  @warned ||= Set.new
62
86
  return unless @warned.add?(location)
63
87
 
64
- message = "Lighthouse: @throttle on #{location} counts nothing, as the cache store is a :null_store. Set Config.cache_store."
88
+ message = "Lighthouse: @throttle on #{location} counts nothing, as the cache store is a :null_store. " \
89
+ 'Set config.action_controller.cache_store (or config.cache_store), as for Rails\' rate_limit.'
65
90
  defined?(Rails) && Rails.logger ? Rails.logger.warn(message) : warn(message)
66
91
  end
67
92
 
68
93
  private
69
94
 
70
- def client(ctx)
71
- user = Lighthouse::Support::Authorization.authenticated_user(ctx)
72
- return "user:#{user.respond_to?(:id) ? user.id : user}" if user
95
+ # rate_limit's by: the value at `by`'s path in the context, else the client's IP.
96
+ def client(limit, ctx)
97
+ limit[:by] ? Lighthouse::Support::Arguments.dig(ctx, limit[:by]).first : ctx[:remote_ip]
98
+ end
99
+
100
+ # Where rate_limit counts: the controller's cache_store; Rails.cache outside one.
101
+ def store(controller)
102
+ controller.respond_to?(:cache_store) ? controller.cache_store : Lighthouse::GraphQL::Caching.store
103
+ end
104
+
105
+ # The count after this call. A store whose increment does not create the key
106
+ # (ActiveSupport before 7.1) starts it with a write.
107
+ def increment(store, key, within, location)
108
+ self.class.warn_null_store(location) if store.is_a?(::ActiveSupport::Cache::NullStore)
109
+ store.increment(key, 1, expires_in: within) || (store.write(key, 1, expires_in: within) && 1)
110
+ end
111
+
112
+ # Past the limit: instrumented as rate_limit's, around `with`, after which a
113
+ # response `with` performed ends the request, as a before_action's would.
114
+ def refuse(controller, limit, location, counted)
115
+ payload = { request: controller&.request, to: limit[:to], within: limit[:within], **counted }
116
+ ActiveSupport::Notifications.instrument('rate_limit.action_controller', payload) { limited(controller, limit[:with], location) }
117
+ raise Lighthouse::GraphQL::Controller::Performed if controller&.performed?
118
+ end
119
+
120
+ # The controller's `with` method, else rate_limit's default `with`.
121
+ def limited(controller, with, location)
122
+ return call_with(controller, with, location) if with && controller
123
+ raise ::ActionController::TooManyRequests if defined?(::ActionController::TooManyRequests)
124
+ return controller.head(:too_many_requests) if controller
125
+
126
+ raise ::GraphQL::ExecutionError, "Rate limit for #{location} exceeded. Try again later."
127
+ end
128
+
129
+ def call_with(controller, with, location)
130
+ unless controller.respond_to?(with, true)
131
+ raise Lighthouse::DefinitionError, "@throttle(with: \"#{with}\") on #{location}: #{controller.class} has no method #{with}."
132
+ end
73
133
 
74
- ip = ctx.respond_to?(:[]) ? ctx[:remote_ip] : nil
75
- ip ? "ip:#{ip}" : 'guest'
134
+ controller.send(with)
76
135
  end
77
136
  end
@@ -10,15 +10,18 @@ require_relative 'base'
10
10
  # `object` (the parent), `context`, the `dataload*` helpers, and the field's
11
11
  # arguments as snake_case keywords. A field with a resolving directive never
12
12
  # reaches it, and one whose name the class does not define is read off the
13
- # object as before. Not a directive: FieldResolverStack installs it on types
14
- # other than the root ones.
13
+ # object as before. Not a directive: FieldResolverStack installs it, on the
14
+ # root types too (Types::QueryType#latest_posts for Query.latestPosts, before
15
+ # the conventional Resolvers::LatestPosts).
15
16
  class Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
16
17
  # The class of the SDL type `type_name` (`Types::PostType` for `Post`) in
17
18
  # Config.namespaces[:types], when it includes Lighthouse::GraphQL::Type; nil
18
19
  # otherwise, so a type class that does not opt in is left alone. It must
19
20
  # inherit from the schema's base_types[:object] (in `bases`, ResolverBases)
20
- # when one is given.
21
- def self.type_class(type_name, bases = {})
21
+ # when one is given. A root type's class is the one it is built on (RootTypes).
22
+ def self.type_class(type_name, bases = {}, root_kind = nil)
23
+ return Lighthouse::GraphQL::RootTypes.type_class(root_kind, bases[:roots]) if root_kind
24
+
22
25
  klass = lookup(type_name)
23
26
  return unless klass
24
27
 
@@ -38,9 +41,12 @@ class Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver < Lighthouse::
38
41
  name if klass.public_method_defined?(name) && !::GraphQL::Schema::Object.public_method_defined?(name)
39
42
  end
40
43
 
41
- def initialize(field_def, type_def, method_name)
44
+ # `root_class`: the root type class the type is built on (RootTypes), used as
45
+ # it is; any other class is looked up by the type's name on each call.
46
+ def initialize(field_def, type_def, method_name, root_class = nil)
42
47
  super(field_def, type_def, nil)
43
48
  @method_name = method_name
49
+ @root_class = root_class
44
50
  end
45
51
 
46
52
  def define_resolver(target_type_class)
@@ -59,7 +65,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver < Lighthouse::
59
65
  # it no longer defines the method, the field is read as before. A parent that
60
66
  # is not a record of the type's @model is reported, and the field resolves to nil.
61
67
  def resolve_with_type(inner, obj, args, ctx)
62
- klass = self.class.lookup(@type_def.graphql_name)
68
+ klass = @root_class || self.class.lookup(@type_def.graphql_name)
63
69
  return inner.call(obj, args, ctx) unless klass&.public_method_defined?(@method_name)
64
70
 
65
71
  model = Lighthouse::GraphQL::Directives::ModelDirective.declared_model(@type_def)
@@ -174,7 +174,7 @@ module Lighthouse::GraphQL::Directives::Arguments
174
174
  Typically, this option should only be used for root fields,
175
175
  as it may cause wrong results with batched relation queries.
176
176
  """
177
- builder: Boolean! = false
177
+ builder: Boolean = false
178
178
  ) on ARGUMENT_DEFINITION | FIELD_DEFINITION
179
179
  GRAPHQL
180
180
  end
@@ -55,20 +55,23 @@ module Lighthouse::GraphQL::FieldResolverStack
55
55
  [0, -> { resolver.define_resolver(type) }]
56
56
  end
57
57
 
58
- # What resolves a field with no resolving directive: the conventional class
59
- # of a root field, or the method of the type's class. nil: the default reader.
58
+ # What resolves a field with no resolving directive: the method of the
59
+ # type's class (Types::QueryType#latest_posts too, as in graphql-ruby), else
60
+ # for a Query or Mutation field the conventional class. nil: the default reader.
60
61
  def own_step(type, field_def, root_kind, bases, type_class)
61
- return convention_step(type, field_def, root_kind, bases) if root_kind
62
+ return convention_step(type, field_def, root_kind, bases) if root_kind == :subscriptions
62
63
 
63
- type_method_step(type, field_def, type_class) if type_class
64
+ step = type_method_step(type, field_def, type_class, root: !root_kind.nil?) if type_class
65
+ step || (convention_step(type, field_def, root_kind, bases) if root_kind)
64
66
  end
65
67
 
66
68
  # Nothing to install when the class does not define the field's method: the
67
69
  # field is read off the object.
68
- def type_method_step(type, field_def, type_class)
70
+ # `root`: the class is the root type's, which the type is built on, not one found by name.
71
+ def type_method_step(type, field_def, type_class, root: false)
69
72
  resolver = Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver
70
73
  method_name = resolver.method_for(type_class, field_def)
71
- [0, -> { resolver.new(field_def, type, method_name).define_resolver(type) }] if method_name
74
+ [0, -> { resolver.new(field_def, type, method_name, root ? type_class : nil).define_resolver(type) }] if method_name
72
75
  end
73
76
 
74
77
  # An explicit @validator on the field already validates its arguments.
@@ -7,6 +7,12 @@ require 'base64'
7
7
  # Config.global_id takes another object with `encode(type, id)` and
8
8
  # `decode(global_id)` -> [type, id], as PHP Lighthouse lets the GlobalId
9
9
  # contract be rebound.
10
+ #
11
+ # A schema that implements graphql-ruby's Relay ids, `id_from_object` and
12
+ # `object_from_id` (graphql-ruby's install writes them, on Rails' GlobalID),
13
+ # has one kind of global id: @globalId encodes an object's own id with
14
+ # `id_from_object`, and decodes with `object_from_id`, as graphql-ruby's
15
+ # Query.node and Node's `id` do.
10
16
  module Lighthouse::GraphQL::GlobalId
11
17
  module_function
12
18
 
@@ -20,12 +26,32 @@ module Lighthouse::GraphQL::GlobalId
20
26
  []
21
27
  end
22
28
 
23
- # [type, id] for a global id a client sent, through Config.global_id; a
29
+ # [type, id] for a global id a client sent, through the schema's
30
+ # object_from_id (schema_ids?) or Config.global_id; a
24
31
  # GraphQL::ExecutionError when it is not one.
25
- def decode!(global_id)
26
- type, id = Lighthouse::Config.global_id.decode(global_id)
32
+ def decode!(global_id, context = nil)
33
+ type, id = schema_ids?(context&.schema) ? decode_object(global_id, context) : Lighthouse::Config.global_id.decode(global_id)
27
34
  return [type, id] if type.present? && !id.nil?
28
35
 
29
36
  raise ::GraphQL::ExecutionError, "#{global_id.inspect} is not a valid global id."
30
37
  end
38
+
39
+ # The global id of `object`'s own id (`id`, what a field of `type` resolved)
40
+ # through the schema's id_from_object (schema_ids?); nil for anything else.
41
+ def encode_field(type, object, id, context)
42
+ return unless schema_ids?(context&.schema) && object.respond_to?(:id) && object.id.to_s == id.to_s
43
+
44
+ context.schema.id_from_object(object, type, context)
45
+ end
46
+
47
+ # Does `schema` define graphql-ruby's object_from_id (GraphQL::Schema's own raises)?
48
+ def schema_ids?(schema)
49
+ schema.respond_to?(:object_from_id) && schema.method(:object_from_id).owner != ::GraphQL::Schema.singleton_class
50
+ end
51
+
52
+ # [type, id] of the object the schema's object_from_id finds.
53
+ def decode_object(global_id, context)
54
+ object = context.schema.object_from_id(global_id, context)
55
+ object.nil? ? [] : [object.class.name.demodulize, object.id]
56
+ end
31
57
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lighthouse
4
4
  module GraphQL
5
- VERSION = '0.5.0'
5
+ VERSION = '0.6.0'
6
6
  end
7
7
  end
@@ -15,4 +15,49 @@ module Lighthouse::GraphQL
15
15
  # skip the fix rather than fail to load (explicit nulls then fail as before).
16
16
  builder = ::GraphQL::Schema::BuildFromDefinition.const_get(:Builder) if ::GraphQL::Schema::BuildFromDefinition.const_defined?(:Builder)
17
17
  builder.prepend(NullDirectiveArguments) if builder && (builder.method_defined?(:args_to_kwargs) || builder.private_method_defined?(:args_to_kwargs))
18
+
19
+ # graphql-ruby builds every SDL object type on base_types[:object], and every
20
+ # interface from base_types[:interface]. RootTypes builds a root type on the
21
+ # app's code-first one (Types::QueryType) instead, and takes the app's Node
22
+ # interface (Types::NodeType) as the SDL's Node.
23
+ module RootTypeBuilder
24
+ def build_object_type(definition, type_resolver, base_type)
25
+ root = Lighthouse::GraphQL::RootTypes.base_for(definition.name)
26
+ return super unless root
27
+
28
+ super(Lighthouse::GraphQL::RootTypes.definition_on(definition, root), type_resolver, root)
29
+ end
30
+
31
+ def build_interface_type(definition, type_resolver, base_type)
32
+ Lighthouse::GraphQL::RootTypes.base_for(definition.name) || super
33
+ end
34
+ end
35
+
36
+ # As above, a graphql-ruby release that reshapes the builder skips the hook:
37
+ # root types are then built on base_types[:object], as before.
38
+ if builder && %i[build_object_type build_interface_type].all? { |name| builder.method_defined?(name) || builder.private_method_defined?(name) }
39
+ builder.prepend(RootTypeBuilder)
40
+ end
41
+
42
+ # The app's schema class inherits Lighthouse's (`class AppSchema <
43
+ # get_schema(...)`), so its Subscription root type is the parent's. A
44
+ # subscriptions backend the subclass `use`s (ActionCableSubscriptions) then
45
+ # finds no root type of the subclass's own, and graphql-ruby adds its
46
+ # subscription extension to no field. Look the root type up through the
47
+ # parent too. Extended onto the schemas Lighthouse builds (SchemaGenerator).
48
+ module InheritedSubscriptionRoot
49
+ def add_subscription_extension_if_necessary
50
+ return super if @subscription_object.is_a?(Class) || !defined?(::GraphQL::Subscriptions::DefaultSubscriptionResolveExtension)
51
+ return unless inherited_subscription_root_pending?
52
+
53
+ @subscription_extension_added = true
54
+ extension = ::GraphQL::Subscriptions::DefaultSubscriptionResolveExtension
55
+ subscription.all_field_definitions.each { |field| field.extension(extension) unless field.extensions.any?(extension) }
56
+ end
57
+
58
+ # A backend, a root type from the parent, and no extension added yet.
59
+ def inherited_subscription_root_pending?
60
+ !defined?(@subscription_extension_added) && subscription.is_a?(Class) && !subscriptions.nil?
61
+ end
62
+ end
18
63
  end
@@ -48,6 +48,7 @@ require_relative 'namespaces'
48
48
  events/execution_trace
49
49
  events/schema_build
50
50
  resolver_bases
51
+ root_types
51
52
  schema_generator
52
53
  schema_cache
53
54
  persisted_queries
@@ -18,6 +18,11 @@
18
18
  # it is first used, so none misses what the base shares;
19
19
  # - for subscriptions, also resolves a Subscription field that has no class of
20
20
  # its own, instead of Lighthouse::GraphQL::Subscription::Default.
21
+ #
22
+ # The code-first types graphql-ruby's install writes are bases too (RootTypes):
23
+ # `query_type:` / `mutation_type:` / `subscription_type:` (Types::QueryType, ...)
24
+ # are what the SDL's root types are built on, so their `field`s are the
25
+ # schema's too, and `node:` (Types::NodeType) is the SDL's Node interface.
21
26
  module Lighthouse::GraphQL::ResolverBases
22
27
  # base_types key => the graphql-ruby class the base must inherit from.
23
28
  GRAPHQL_CLASSES = {
@@ -35,19 +40,28 @@ module Lighthouse::GraphQL::ResolverBases
35
40
  # (Types::PostType, Types::DateTimeType, ...) inherit from too.
36
41
  TYPE_KINDS = %i[object union interface scalar].freeze
37
42
 
38
- # Take the three keys out of opts[:base_types] (the caller's Hash is not
43
+ # Take Lighthouse's keys out of opts[:base_types] (the caller's Hash is not
39
44
  # changed) and return { root kind => base class } for those given, plus
40
- # :types => { kind => base } for graphql-ruby's own, which stay in opts.
45
+ # :types => { kind => base } for graphql-ruby's own, which stay in opts, and
46
+ # :roots => { query_type: ..., node: ... } for the code-first types (RootTypes).
41
47
  def extract!(opts)
42
48
  base_types = opts[:base_types]
43
49
  return {} unless base_types.is_a?(Hash)
44
50
 
45
51
  given = base_types.slice(*GRAPHQL_CLASSES.keys).compact
46
- opts[:base_types] = base_types.except(*GRAPHQL_CLASSES.keys)
52
+ opts[:base_types] = base_types.except(*GRAPHQL_CLASSES.keys, *Lighthouse::GraphQL::RootTypes::KEYS)
47
53
  given.each { |key, klass| validate!(key, klass) }
48
54
  bases = KEYS.filter_map { |kind, key| [kind, given[key]] if given[key] }.to_h
49
55
  type_bases = base_types.slice(*TYPE_KINDS).compact
50
- type_bases.empty? ? bases : bases.merge(types: type_bases)
56
+ bases = bases.merge(types: type_bases) unless type_bases.empty?
57
+ with_roots(bases, base_types)
58
+ end
59
+
60
+ # `bases` with the code-first root types given, validated, under :roots.
61
+ def with_roots(bases, base_types)
62
+ roots = base_types.slice(*Lighthouse::GraphQL::RootTypes::KEYS).compact
63
+ roots.each { |key, type| Lighthouse::GraphQL::RootTypes.validate!(key, type) }
64
+ roots.empty? ? bases : bases.merge(roots: roots)
51
65
  end
52
66
 
53
67
  def validate!(key, klass)
@@ -32,7 +32,22 @@ module Lighthouse::GraphQL::ResolverDispatch
32
32
  kwargs = Lighthouse::Support::Arguments.prepare(info.field, info.arguments, info.context)
33
33
  instance = Lighthouse::GraphQL::ResolveInfo.attach(klass.new(object: info.object, context: info.context, field: info.field), info)
34
34
  instance.define_singleton_method(:resolve) { |**values| public_send(method, **values) } if method
35
- instance.resolve_with_support(**kwargs)
35
+ single_input?(instance, kwargs) ? instance.resolve_with_support(**kwargs) : resolve_as_mutation(instance, kwargs)
36
+ end
37
+
38
+ # graphql-ruby's RelayClassicMutation (the base mutation its install writes)
39
+ # takes one `input:` argument. A field that declares its arguments one by one
40
+ # calls it as a plain GraphQL::Schema::Mutation, with them as keywords: past
41
+ # HasSingleInputArgument's unwrapping of `input:`, and its authorization of
42
+ # the arguments of `input:`.
43
+ def single_input?(instance, kwargs)
44
+ !instance.is_a?(::GraphQL::Schema::HasSingleInputArgument) || kwargs.key?(:input)
45
+ end
46
+
47
+ def resolve_as_mutation(instance, kwargs)
48
+ instance.define_singleton_method(:authorize_arguments, ::GraphQL::Schema::Resolver.instance_method(:authorize_arguments))
49
+ instance.singleton_class.send(:private, :authorize_arguments)
50
+ ::GraphQL::Schema::Mutation.instance_method(:resolve_with_support).bind_call(instance, **kwargs)
36
51
  end
37
52
 
38
53
  # The class itself when it answers `name` as a class method, else an instance.
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The code-first types graphql-ruby's install writes, which a schema builds on:
4
+ #
5
+ # base_types: { query_type: Types::QueryType, mutation_type: Types::MutationType,
6
+ # subscription_type: Types::SubscriptionType, node: Types::NodeType }
7
+ #
8
+ # - A root type given is what the SDL's root type of that kind is built on,
9
+ # instead of base_types[:object]: the SDL's `type Query` is a subclass of
10
+ # Types::QueryType, so the `field`s the class declares (graphql-ruby's
11
+ # `node` / `nodes`, what `graphql:mutation` adds to Types::MutationType)
12
+ # are the schema's too, beside the SDL's, and resolve as in graphql-ruby. An
13
+ # SDL field of the same name wins. With no such root type in the SDL, the
14
+ # class itself is the root, once it declares a field.
15
+ # - `node:` is the SDL's Node interface: `interface Node`, which @node adds and
16
+ # `implements Node` names, is that module, so graphql-ruby's Relay
17
+ # (Query.node / nodes, the schema's id_from_object / object_from_id) and the
18
+ # SDL's @node types share one Node. The `node` field @node adds to Query then
19
+ # gives way to the root type class's own.
20
+ #
21
+ # graphql-ruby's builder knows one base per kind, so the hook into it is a
22
+ # patch (graphql_ruby_patches.rb), which reads the bases of the build in
23
+ # progress from here.
24
+ module Lighthouse::GraphQL::RootTypes
25
+ KEYS = %i[query_type mutation_type subscription_type node].freeze
26
+
27
+ ROOTS = { query_type: :query, mutation_type: :mutation, subscription_type: :subscription }.freeze
28
+
29
+ # The resolver of the `node` field @node adds to Query.
30
+ NODE_RESOLVER = 'Lighthouse::GraphQL::NodeRegistry@resolve'
31
+
32
+ module_function
33
+
34
+ def validate!(key, type)
35
+ valid = key == :node ? Lighthouse::GraphQL::Type.kind_of(type) == :interface : type.is_a?(Class) && type < ::GraphQL::Schema::Object
36
+ return if valid
37
+
38
+ expected = key == :node ? 'a module including GraphQL::Schema::Interface' : 'a subclass of GraphQL::Schema::Object'
39
+ raise Lighthouse::DefinitionError, "base_types[:#{key}] must be #{expected}, not #{type.inspect}."
40
+ end
41
+
42
+ # The root type class the SDL's root type of `root_kind` (:queries,
43
+ # :mutations) is built on, when it is Lighthouse's (Lighthouse::GraphQL::Type):
44
+ # its methods resolve the root fields of their name.
45
+ def type_class(root_kind, roots)
46
+ klass = roots&.[]({ queries: :query_type, mutations: :mutation_type }[root_kind])
47
+ klass if klass&.include?(Lighthouse::GraphQL::Type)
48
+ end
49
+
50
+ # Run the block (the build of `document`) with `roots` as the bases of the
51
+ # types they name.
52
+ def with(document, roots)
53
+ previous = Thread.current[:lighthouse_root_types]
54
+ Thread.current[:lighthouse_root_types] = by_type_name(document, roots || {})
55
+ yield
56
+ ensure
57
+ Thread.current[:lighthouse_root_types] = previous
58
+ end
59
+
60
+ # The class an SDL type of `name` is built on, or nil.
61
+ def base_for(name)
62
+ Thread.current[:lighthouse_root_types]&.[](name)
63
+ end
64
+
65
+ # `definition` without the fields `base` declares itself that @node added.
66
+ def definition_on(definition, base)
67
+ names = field_names(base)
68
+ fields = definition.fields.reject { |field| names.include?(field.name) && added_by_node?(field) }
69
+ fields.size == definition.fields.size ? definition : definition.merge(fields: fields)
70
+ end
71
+
72
+ # The names of the fields a class and its ancestors declare. Not `fields`,
73
+ # which warns about a type with none (graphql-ruby's install leaves
74
+ # Types::MutationType empty once its example field goes).
75
+ def field_names(type)
76
+ type.ancestors.select { |ancestor| ancestor.respond_to?(:own_fields) }.flat_map { |ancestor| ancestor.own_fields.keys }.uniq
77
+ end
78
+
79
+ # The schema the block builds from `document`, with `roots` as the bases of
80
+ # the types they name; then the root type classes the SDL has no type for,
81
+ # and the subscriptions backend an app schema class `use`s (graphql_ruby_patches.rb).
82
+ def build(document, roots, &block)
83
+ schema = with(document, roots, &block)
84
+ apply_missing(schema, roots)
85
+ schema.extend(Lighthouse::GraphQL::InheritedSubscriptionRoot) if schema.respond_to?(:add_subscription_extension_if_necessary, true)
86
+ schema
87
+ end
88
+
89
+ # A root type class the SDL has no type for is the root itself, once it
90
+ # declares a field (graphql-ruby's install writes them with an example one).
91
+ def apply_missing(schema, roots)
92
+ ROOTS.each do |key, root|
93
+ type = roots&.[](key)
94
+ next if type.nil? || schema.public_send(root) || field_names(type).empty?
95
+
96
+ schema.public_send(root, type)
97
+ end
98
+ end
99
+
100
+ def by_type_name(document, roots)
101
+ names = root_names(document)
102
+ mapped = ROOTS.each_with_object({}) { |(key, root), out| out[names[root]] = roots[key] if roots[key] }
103
+ roots[:node] ? mapped.merge('Node' => roots[:node]) : mapped
104
+ end
105
+
106
+ # { query: 'Query', ... }, as the document's `schema { }` names them.
107
+ def root_names(document)
108
+ schema = document.definitions.find { |definition| definition.is_a?(::GraphQL::Language::Nodes::SchemaDefinition) }
109
+ { query: schema&.query || 'Query', mutation: schema&.mutation || 'Mutation', subscription: schema&.subscription || 'Subscription' }
110
+ end
111
+
112
+ def added_by_node?(field)
113
+ field.directives.any? do |directive|
114
+ directive.name == 'field' && Lighthouse::Support::DirectiveArgs.literal(directive, 'resolver') == NODE_RESOLVER
115
+ end
116
+ end
117
+ end
@@ -47,7 +47,7 @@ module Lighthouse::GraphQL::SchemaGenerator
47
47
  impl = default_implementation
48
48
  # base_types' resolver / mutation / subscription are Lighthouse's, not graphql-ruby's.
49
49
  bases = Lighthouse::GraphQL::ResolverBases.extract!(opts)
50
- schema = from_document(document, impl, opts)
50
+ schema = Lighthouse::GraphQL::RootTypes.build(document, bases[:roots]) { from_document(document, impl, opts) }
51
51
 
52
52
  delegate_to_federation_sdl(schema)
53
53
  Lighthouse::GraphQL::GeneratedResolvers.install_dispatcher(schema)
@@ -140,8 +140,9 @@ module Lighthouse::GraphQL::SchemaGenerator
140
140
  # Apollo Federation helper can detect entities and add _entities.
141
141
  Lighthouse::GraphQL::FederationDirectives.apply(type)
142
142
  root_kind = root_kinds[type.graphql_name]
143
- type_class = root_kind ? nil : Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver.type_class(type.graphql_name, bases)
144
- type.fields.each_value { |field_def| process_field(type, field_def, root_kind, bases, type_class) }
143
+ type_class = Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver.type_class(type.graphql_name, bases, root_kind)
144
+ # A field a code-first base declares (RootTypes) resolves as graphql-ruby resolves it.
145
+ type.fields.each_value { |field_def| process_field(type, field_def, root_kind, bases, type_class) if field_def.owner == type }
145
146
  end
146
147
 
147
148
  # @complexity, and paginated fields, give graphql-ruby's complexity analysis their cost.
@@ -13,11 +13,13 @@ class Lighthouse::GraphQL::Upload < GraphQL::Schema::Scalar
13
13
  def self.coerce_input(value, _context)
14
14
  return value if file?(value)
15
15
 
16
- raise ::GraphQL::CoercionError, 'Could not get uploaded file, be sure to conform to the GraphQL multipart request specification.'
16
+ raise ::GraphQL::CoercionError,
17
+ 'Could not get uploaded file, be sure to conform to GraphQL multipart request specification: ' \
18
+ "https://github.com/jaydenseric/graphql-multipart-request-spec. Instead got: #{value.inspect}."
17
19
  end
18
20
 
19
21
  def self.coerce_result(_value, _context)
20
- raise ::GraphQL::Error, 'The Upload scalar can only be used as an argument, not returned.'
22
+ raise ::GraphQL::Error, '"Upload" cannot be serialized, it can only be used as an argument.'
21
23
  end
22
24
 
23
25
  def self.file?(value)