lighthouse-graphql 0.4.0 → 0.5.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -1
  3. data/README.md +8 -5
  4. data/docs/configuration.md +26 -2
  5. data/docs/directives.md +287 -4
  6. data/docs/generators.md +82 -0
  7. data/docs/getting-started.md +26 -30
  8. data/docs/relationships.md +42 -0
  9. data/docs/relay.md +137 -0
  10. data/docs/resolvers.md +261 -22
  11. data/docs/security.md +17 -0
  12. data/docs/types.md +219 -0
  13. data/docs/uploads.md +80 -0
  14. data/lib/generators/lighthouse/base_classes.rb +32 -49
  15. data/lib/generators/lighthouse/install/install_generator.rb +83 -24
  16. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +14 -6
  17. data/lib/generators/lighthouse/install/templates/schema.rb.tt +10 -3
  18. data/lib/lighthouse/async_mutation_job.rb +17 -0
  19. data/lib/lighthouse/complexity.rb +5 -4
  20. data/lib/lighthouse/config.rb +13 -2
  21. data/lib/lighthouse/controller.rb +27 -9
  22. data/lib/lighthouse/dataloader_sources/preload_loader.rb +16 -0
  23. data/lib/lighthouse/dataloader_sources/relation_counts_loader.rb +46 -0
  24. data/lib/lighthouse/default_resolve_dispatcher.rb +26 -0
  25. data/lib/lighthouse/directive_resolvers/all_resolver.rb +1 -1
  26. data/lib/lighthouse/directive_resolvers/argument_binding.rb +61 -0
  27. data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +220 -0
  28. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +2 -2
  29. data/lib/lighthouse/directive_resolvers/async_resolver.rb +90 -0
  30. data/lib/lighthouse/directive_resolvers/base.rb +63 -14
  31. data/lib/lighthouse/directive_resolvers/builder_applier.rb +89 -19
  32. data/lib/lighthouse/directive_resolvers/can_resolvers.rb +1 -1
  33. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +2 -1
  34. data/lib/lighthouse/directive_resolvers/count_resolver.rb +1 -1
  35. data/lib/lighthouse/directive_resolvers/field_resolver.rb +35 -7
  36. data/lib/lighthouse/directive_resolvers/first_resolver.rb +1 -1
  37. data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +57 -0
  38. data/lib/lighthouse/directive_resolvers/lazy_load_resolver.rb +61 -0
  39. data/lib/lighthouse/directive_resolvers/limit_resolver.rb +59 -0
  40. data/lib/lighthouse/directive_resolvers/method_resolver.rb +38 -3
  41. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +1 -1
  42. data/lib/lighthouse/directive_resolvers/namespaced_resolver.rb +31 -0
  43. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +1 -1
  44. data/lib/lighthouse/directive_resolvers/resolution.rb +19 -4
  45. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +2 -1
  46. data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +77 -0
  47. data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +81 -0
  48. data/lib/lighthouse/directive_resolvers/with_count_resolver.rb +43 -0
  49. data/lib/lighthouse/directive_resolvers/with_resolver.rb +42 -0
  50. data/lib/lighthouse/directives/arguments/more_filters.rb +215 -0
  51. data/lib/lighthouse/directives/definitions.rb +52 -0
  52. data/lib/lighthouse/directives/model_directive.rb +131 -0
  53. data/lib/lighthouse/directives/node_directive.rb +134 -0
  54. data/lib/lighthouse/directives/pagination_manipulator.rb +1 -1
  55. data/lib/lighthouse/directives/registry.rb +21 -21
  56. data/lib/lighthouse/directives/visibility_directive.rb +71 -0
  57. data/lib/lighthouse/enum_directive.rb +41 -0
  58. data/lib/lighthouse/field_resolver_stack.rb +53 -6
  59. data/lib/lighthouse/generated_resolvers.rb +13 -8
  60. data/lib/lighthouse/global_id.rb +31 -0
  61. data/lib/lighthouse/graphql/version.rb +1 -1
  62. data/lib/lighthouse/multipart_request.rb +43 -0
  63. data/lib/lighthouse/node_registry.rb +68 -0
  64. data/lib/lighthouse/pagination.rb +3 -0
  65. data/lib/lighthouse/rb_lighthouse.rb +33 -0
  66. data/lib/lighthouse/relation_counts.rb +29 -0
  67. data/lib/lighthouse/resolve_info.rb +80 -0
  68. data/lib/lighthouse/resolver_bases.rb +9 -2
  69. data/lib/lighthouse/resolver_dispatch.rb +36 -11
  70. data/lib/lighthouse/scalar_coercion.rb +72 -0
  71. data/lib/lighthouse/schema_cache.rb +2 -1
  72. data/lib/lighthouse/schema_generator.rb +11 -10
  73. data/lib/lighthouse/schema_implementation.rb +4 -8
  74. data/lib/lighthouse/support/environment.rb +13 -0
  75. data/lib/lighthouse/support/model_resolver.rb +7 -5
  76. data/lib/lighthouse/support/preload.rb +49 -0
  77. data/lib/lighthouse/type.rb +103 -0
  78. data/lib/lighthouse/type_resolution.rb +150 -0
  79. data/lib/lighthouse/upload.rb +26 -0
  80. data/lib/lighthouse/upload_store.rb +32 -0
  81. data/sig/lighthouse/directives.rbs +3 -2
  82. data/sig/lighthouse.rbs +18 -0
  83. metadata +38 -1
@@ -23,7 +23,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::FirstDirectiveResolver < Lighthou
23
23
  args_h = arguments.respond_to?(:to_h) ? arguments.to_h : {}
24
24
  relation = resolver_instance.apply_scopes(model_class.all)
25
25
  relation = Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.apply(relation, field_ast_node, args_h, ctx, resolver_instance)
26
- relation = resolver_instance.apply_where_filters(relation, args_h)
26
+ relation = resolver_instance.apply_where_filters(relation, args_h, ctx)
27
27
  relation.first
28
28
  end
29
29
 
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @globalId — Relay global ids, as PHP Lighthouse's directive: on a field it
6
+ # encodes the value with the type's name (`id: ID! @globalId` answers
7
+ # "VXNlcjox" for User 1); on an argument or input field it decodes what the
8
+ # client sent (ArgumentTransformsResolver). Config.global_id does the encoding.
9
+ class Lighthouse::GraphQL::DirectiveResolvers::GlobalIdDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
10
+ NAME = 'globalId'
11
+
12
+ def self.directive_name = NAME
13
+
14
+ def self.definition
15
+ <<~GRAPHQL.chomp
16
+ """
17
+ Converts between IDs/types and global IDs.
18
+
19
+ When used upon a field, it encodes;
20
+ when used upon an argument, it decodes.
21
+ """
22
+ directive @globalId(
23
+ """
24
+ Decoding a global id produces a tuple of `$type` and `$id`.
25
+ This setting controls which of those is passed along.
26
+ """
27
+ decode: GlobalIdDecode = ARRAY
28
+ ) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
29
+ GRAPHQL
30
+ end
31
+
32
+ def self.type_definitions
33
+ {
34
+ 'GlobalIdDecode' => <<~GRAPHQL.chomp
35
+ "Options for the `decode` argument of `@globalId`."
36
+ enum GlobalIdDecode {
37
+ "Return an array of `[$type, $id]`."
38
+ ARRAY
39
+ "Return just `$type`."
40
+ TYPE
41
+ "Return just `$id`."
42
+ ID
43
+ }
44
+ GRAPHQL
45
+ }
46
+ end
47
+
48
+ def define_resolver(target_type_class)
49
+ inner = capture_inner(target_type_class)
50
+ type_name = target_type_class.graphql_name
51
+
52
+ install(target_type_class, lambda do |obj, args, ctx|
53
+ id = inner.call(obj, args, ctx)
54
+ id.nil? ? nil : Lighthouse::Config.global_id.encode(type_name, id)
55
+ end)
56
+ end
57
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @lazyLoad(relations: ["replies"]) — eager-load relations of the records a field
6
+ # returns, as PHP Lighthouse's @lazyLoad does, so the fields the client selects on
7
+ # them do not query once per record. Works on a list, a relation, and a @paginate
8
+ # page; across every parent in the response at once, so a @hasMany's records for
9
+ # all its parents load their relations together.
10
+ #
11
+ # type Post {
12
+ # comments: [Comment!]! @hasMany @lazyLoad(relations: ["replies", "author"])
13
+ # }
14
+ class Lighthouse::GraphQL::DirectiveResolvers::LazyLoadDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
15
+ def self.directive_name = 'lazyLoad'
16
+
17
+ def self.definition
18
+ <<~GRAPHQL.chomp
19
+ """
20
+ Eager-load relations of the records the field returns.
21
+ """
22
+ directive @lazyLoad(
23
+ "The relations to load, nested with dots."
24
+ relations: [String!]!
25
+ ) repeatable on FIELD_DEFINITION
26
+ GRAPHQL
27
+ end
28
+
29
+ def define_resolver(target_type_class)
30
+ inner = capture_inner(target_type_class)
31
+ associations = Lighthouse::Support::Preload.associations(directive_arg('relations'))
32
+
33
+ install(target_type_class, lambda do |obj, args, ctx|
34
+ Lighthouse::GraphQL::DirectiveResolvers::LazyLoadDirectiveResolver.load(inner.call(obj, args, ctx), associations, ctx)
35
+ end)
36
+ end
37
+
38
+ # `result` with `associations` loaded into its records.
39
+ def self.load(result, associations, ctx)
40
+ return result.preload(*associations) if unloaded_relation?(result)
41
+
42
+ records = records_of(result).grep(::ActiveRecord::Base)
43
+ ctx.dataloader.with(Lighthouse::GraphQL::DataloaderSources::PreloadLoader, associations, []).load_all(records) if records.any?
44
+ result
45
+ end
46
+
47
+ # A query not run yet takes the preload itself. An association's records (what
48
+ # @hasMany returns, loaded for every parent at once) or a loaded relation
49
+ # are records already: they load their relations together, in the Dataloader.
50
+ def self.unloaded_relation?(result)
51
+ result.is_a?(::ActiveRecord::Relation) && !result.is_a?(::ActiveRecord::Associations::CollectionProxy) && !result.loaded?
52
+ end
53
+
54
+ def self.records_of(result)
55
+ case result
56
+ when Lighthouse::GraphQL::Pagination::LazyPage then result.items
57
+ when Hash then Array(result['data'] || result[:data])
58
+ else Array(result)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @limit on an argument — the client caps how many results the field returns,
6
+ # as PHP Lighthouse's directive: `posts(limit: Int @limit): [Post!]! @all`. The
7
+ # argument must be an Int; null leaves the results alone. A query not run yet
8
+ # gets a LIMIT; a list already loaded (a @hasMany's records, loaded for every
9
+ # parent at once) is cut in memory, so one parent's limit never cuts another's.
10
+ # With `builder: true` it is a LIMIT on the field's query instead, through the
11
+ # argument directives (Arguments::Limit): for root fields, as PHP Lighthouse
12
+ # warns, since a relation batched for many parents would be cut as a whole.
13
+ # PHP Lighthouse also allows @limit on the field, where it marks the field the
14
+ # argument limits; it is accepted there and changes nothing. Not a directive
15
+ # resolver: FieldResolverStack installs it on a field whose arguments carry
16
+ # @limit, around whatever resolves the field.
17
+ class Lighthouse::GraphQL::DirectiveResolvers::LimitResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
18
+ # The keyword of the argument whose @limit cuts the results, or nil; every
19
+ # argument carrying @limit must be an Int.
20
+ def self.keyword(field_def)
21
+ limiting = field_def.arguments.each_value.select { |argument| directive(argument) }
22
+ limiting.each { |argument| validate!(field_def, argument) }
23
+ limiting.find { |argument| !Lighthouse::Support::DirectiveArgs.literal(directive(argument), 'builder') }&.keyword
24
+ end
25
+
26
+ def self.directive(argument)
27
+ (argument.ast_node&.directives || []).find { |d| d.name == 'limit' }
28
+ end
29
+
30
+ def self.validate!(field_def, argument)
31
+ return if argument.type.unwrap.graphql_name == 'Int'
32
+
33
+ raise Lighthouse::DefinitionError,
34
+ "The @limit directive must only be used on arguments of type Int, got #{field_def.name}.#{argument.graphql_name} of type " \
35
+ "#{argument.type.unwrap.graphql_name}."
36
+ end
37
+
38
+ def define_resolver(target_type_class)
39
+ keyword = self.class.keyword(@field_def)
40
+ inner = capture_inner(target_type_class)
41
+
42
+ install(target_type_class, lambda do |obj, args, ctx|
43
+ limit = args.is_a?(Hash) ? args[keyword] : nil
44
+ result = inner.call(obj, args, ctx)
45
+ limit.nil? ? result : Lighthouse::GraphQL::DirectiveResolvers::LimitResolver.cut(result, limit)
46
+ end)
47
+ end
48
+
49
+ def self.cut(result, limit)
50
+ raise ::GraphQL::ExecutionError, "The limit must be positive, got #{limit}." if limit.negative?
51
+
52
+ case result
53
+ when ::ActiveRecord::Associations::CollectionProxy then result.to_a.first(limit)
54
+ when ::ActiveRecord::Relation then result.loaded? ? result.to_a.first(limit) : result.limit(limit)
55
+ when Array then result.first(limit)
56
+ else result
57
+ end
58
+ end
59
+ end
@@ -8,6 +8,12 @@ require_relative 'base'
8
8
  #
9
9
  # labels: [String!]! @method(name: "label_list")
10
10
  # canReply: Boolean @method(name: "can_reply?")
11
+ # excerpt(length: Int = 80): String @method
12
+ #
13
+ # The field's arguments reach the method: as snake_case keywords when it takes
14
+ # keywords (`def excerpt(length:)`), else positionally in the order the SDL
15
+ # declares them, as PHP Lighthouse passes them (`def excerpt(length)`). A method
16
+ # taking no parameters is called without them.
11
17
  class Lighthouse::GraphQL::DirectiveResolvers::MethodDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
12
18
  def self.directive_name = 'method'
13
19
  def self.definition = 'directive @method(name: String) on FIELD_DEFINITION'
@@ -17,13 +23,42 @@ class Lighthouse::GraphQL::DirectiveResolvers::MethodDirectiveResolver < Lightho
17
23
  target_method = (Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'name') ||
18
24
  Lighthouse::Support::Naming.attribute(@field_def.name)).to_s
19
25
 
20
- resolver = lambda do |obj, _arguments, _ctx|
21
- next nil if obj.nil?
26
+ field_def = @field_def
27
+ resolver = lambda do |obj, arguments, ctx|
28
+ next nil if obj.nil? || !obj.respond_to?(target_method)
22
29
 
23
- obj.respond_to?(target_method) ? obj.public_send(target_method) : nil
30
+ Lighthouse::GraphQL::DirectiveResolvers::MethodDirectiveResolver.call_method(obj.method(target_method), field_def, arguments, ctx)
24
31
  end
25
32
 
26
33
  target_type_class.define_singleton_method(resolver_method, &resolver)
27
34
  @field_def.resolve_proc = resolver if @field_def.respond_to?(:resolve_proc=)
28
35
  end
36
+
37
+ KEYWORDS = %i[key keyreq keyrest].freeze
38
+ NAMED_KEYWORDS = %i[key keyreq].freeze
39
+
40
+ # Call `method` with the field's arguments, as the method takes them.
41
+ def self.call_method(method, field_def, arguments, ctx)
42
+ params = method.parameters
43
+ return method.call if field_def.arguments.empty? || params.empty?
44
+
45
+ values = Lighthouse::Support::Arguments.prepare(field_def, arguments, ctx)
46
+ return method.call(**keywords(params, values)) if params.any? { |type, _| KEYWORDS.include?(type) }
47
+
48
+ method.call(*positional(params, field_def, values))
49
+ end
50
+
51
+ # The values in the order the SDL declares the arguments, as many as the
52
+ # method takes; all of them when it takes *rest.
53
+ def self.positional(params, field_def, values)
54
+ ordered = field_def.arguments.each_value.map { |arg| values[arg.keyword] }
55
+ params.any? { |type, _| type == :rest } ? ordered : ordered.first(params.size)
56
+ end
57
+
58
+ # The values the method's keywords name; all of them when it takes **keywords.
59
+ def self.keywords(params, values)
60
+ return values if params.any? { |type, _| type == :keyrest }
61
+
62
+ values.slice(*params.filter_map { |type, name| name if NAMED_KEYWORDS.include?(type) })
63
+ end
29
64
  end
@@ -196,7 +196,7 @@ module Lighthouse::GraphQL::DirectiveResolvers
196
196
  args = Lighthouse::Support::Arguments.shallow(args)
197
197
  raise ::GraphQL::ExecutionError, WOULD_MODIFY_ALL unless filtered?(args)
198
198
 
199
- records = apply_where_filters(records_of(model), args).to_a
199
+ records = apply_where_filters(records_of(model), args, ctx).to_a
200
200
  records.each do |record|
201
201
  next if modify(record)
202
202
 
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @namespaced — a field that only groups others, as PHP Lighthouse's
6
+ # directive: it resolves to `true`, and the fields of its type resolve with
7
+ # their own directives.
8
+ #
9
+ # type Query { post: PostQueries! @namespaced }
10
+ # type PostQueries {
11
+ # find(id: ID! @eq): Post @find
12
+ # list: [Post!]! @paginate
13
+ # }
14
+ #
15
+ # `{ post { find(id: 1) { title } } }`. Works on Mutation too.
16
+ class Lighthouse::GraphQL::DirectiveResolvers::NamespacedDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
17
+ def self.directive_name = 'namespaced'
18
+
19
+ def self.definition
20
+ <<~GRAPHQL.chomp
21
+ """
22
+ A no-op field resolver that allows nesting of queries and mutations.
23
+ """
24
+ directive @namespaced on FIELD_DEFINITION
25
+ GRAPHQL
26
+ end
27
+
28
+ def define_resolver(target_type_class)
29
+ install(target_type_class, ->(_obj, _args, _ctx) { true })
30
+ end
31
+ end
@@ -75,7 +75,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::PaginateDirectiveResolver < Light
75
75
  # `scopes:`, @builder and the argument filters, on one relation or a RelationBatch.
76
76
  def decorate_relation(relation, args, ctx)
77
77
  relation = Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.apply(apply_scopes(relation), @field_def.ast_node, args, ctx, self)
78
- apply_where_filters(relation, args)
78
+ apply_where_filters(relation, args, ctx)
79
79
  end
80
80
 
81
81
  protected
@@ -18,15 +18,30 @@ module Lighthouse::GraphQL::DirectiveResolvers::Resolution
18
18
  original_singleton.call(obj, arguments, ctx)
19
19
  elsif original_proc
20
20
  original_proc.call(obj, arguments, ctx)
21
- elsif obj.respond_to?(field_name)
22
- obj.public_send(field_name)
23
21
  else
24
- snake = Lighthouse::Support::Naming.attribute(field_name)
25
- obj.respond_to?(snake) ? obj.public_send(snake) : nil
22
+ Lighthouse::GraphQL::DirectiveResolvers::Resolution.read(obj, field_name)
26
23
  end
27
24
  end
28
25
  end
29
26
 
27
+ # The default reader: a Hash's key, else the object's method of the field's
28
+ # name or its snake_case.
29
+ def read(obj, field_name)
30
+ return read_hash(obj, field_name) if obj.is_a?(Hash)
31
+ return obj.public_send(field_name) if obj.respond_to?(field_name)
32
+
33
+ snake = Lighthouse::Support::Naming.attribute(field_name)
34
+ obj.respond_to?(snake) ? obj.public_send(snake) : nil
35
+ end
36
+
37
+ # A Hash's value for the field, as the default reader finds it: by the field's
38
+ # name or its snake_case, as a String or a Symbol key.
39
+ def read_hash(hash, field_name)
40
+ snake = Lighthouse::Support::Naming.attribute(field_name)
41
+ key = [field_name.to_s, field_name.to_sym, snake, snake.to_sym].find { |candidate| hash.key?(candidate) }
42
+ key.nil? ? nil : hash[key]
43
+ end
44
+
30
45
  # graphql-ruby's SDL builder defines `resolve_field_*` methods of its own
31
46
  # (with a different arity); only ours, defined under lib/lighthouse/, count.
32
47
  def lighthouse_singleton(target_type_class, method_name)
@@ -32,7 +32,8 @@ class Lighthouse::GraphQL::DirectiveResolvers::SubscriptionDirectiveResolver < L
32
32
  @field_def.define_singleton_method(:subscription_scope) { me.subscription_class.subscription_scope }
33
33
 
34
34
  install(target_type_class, lambda do |obj, args, ctx|
35
- Lighthouse::GraphQL::ResolverDispatch.call(me.subscription_class, me.field_def, obj, args, ctx)
35
+ info = Lighthouse::GraphQL::ResolveInfo.for(me.field_def, target_type_class, obj, args, ctx)
36
+ Lighthouse::GraphQL::ResolverDispatch.call(me.subscription_class, info)
36
37
  end)
37
38
  end
38
39
 
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
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.
11
+ #
12
+ # type Mutation {
13
+ # login(email: String!, password: String!): User @throttle(maxAttempts: 5)
14
+ # search(term: String!): [Post!]! @throttle(name: "search", maxAttempts: 30, decayMinutes: 0.5)
15
+ # }
16
+ class Lighthouse::GraphQL::DirectiveResolvers::ThrottleDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
17
+ def self.directive_name = 'throttle'
18
+
19
+ def self.definition
20
+ <<~GRAPHQL.chomp
21
+ """
22
+ Sets rate limit to access the field. Does the same as ThrottleRequests Laravel Middleware.
23
+ """
24
+ directive @throttle(
25
+ "A name shared by the fields that count together. Defaults to the field."
26
+ name: String
27
+ "Maximum number of attempts in a specified time interval."
28
+ maxAttempts: Int = 60
29
+ "Time in minutes to reset attempts."
30
+ decayMinutes: Float = 1.0
31
+ "Prefix to distinguish several field groups."
32
+ prefix: String
33
+ ) on FIELD_DEFINITION
34
+ GRAPHQL
35
+ end
36
+
37
+ def define_resolver(target_type_class)
38
+ me = self
39
+ inner = capture_inner(target_type_class)
40
+ 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
44
+
45
+ install(target_type_class, lambda do |obj, args, ctx|
46
+ me.hit!(bucket, ctx, max_attempts, decay, location)
47
+ inner.call(obj, args, ctx)
48
+ end)
49
+ end
50
+
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
57
+ end
58
+
59
+ # A :null_store keeps no count, so the limit never applies: say so, once per field.
60
+ def self.warn_null_store(location)
61
+ @warned ||= Set.new
62
+ return unless @warned.add?(location)
63
+
64
+ message = "Lighthouse: @throttle on #{location} counts nothing, as the cache store is a :null_store. Set Config.cache_store."
65
+ defined?(Rails) && Rails.logger ? Rails.logger.warn(message) : warn(message)
66
+ end
67
+
68
+ private
69
+
70
+ def client(ctx)
71
+ user = Lighthouse::Support::Authorization.authenticated_user(ctx)
72
+ return "user:#{user.respond_to?(:id) ? user.id : user}" if user
73
+
74
+ ip = ctx.respond_to?(:[]) ? ctx[:remote_ip] : nil
75
+ ip ? "ip:#{ip}" : 'guest'
76
+ end
77
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # Resolves a field of an object type through a method of the type's class, in
6
+ # graphql-ruby's folder structure: `Post.readingTime` calls
7
+ # `Types::PostType#reading_time` when that class includes Lighthouse::GraphQL::Type
8
+ # and defines it. The SDL still declares the field; the class only says how it
9
+ # resolves, as a graphql-ruby type's method does: an instance per parent, with
10
+ # `object` (the parent), `context`, the `dataload*` helpers, and the field's
11
+ # arguments as snake_case keywords. A field with a resolving directive never
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.
15
+ class Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
16
+ # The class of the SDL type `type_name` (`Types::PostType` for `Post`) in
17
+ # Config.namespaces[:types], when it includes Lighthouse::GraphQL::Type; nil
18
+ # otherwise, so a type class that does not opt in is left alone. It must
19
+ # inherit from the schema's base_types[:object] (in `bases`, ResolverBases)
20
+ # when one is given.
21
+ def self.type_class(type_name, bases = {})
22
+ klass = lookup(type_name)
23
+ return unless klass
24
+
25
+ Lighthouse::GraphQL::ResolverBases.check!(klass, bases.dig(:types, :object), "the #{type_name} type")
26
+ klass
27
+ end
28
+
29
+ def self.lookup(type_name)
30
+ Lighthouse::GraphQL::Type.class_for(type_name, :object)
31
+ end
32
+
33
+ # The method of `klass` that resolves `field_def` (`reading_time` for
34
+ # `readingTime`), or nil. Methods every GraphQL::Schema::Object has
35
+ # (`object`, `context`, Object's own) never count.
36
+ def self.method_for(klass, field_def)
37
+ name = Lighthouse::Support::Naming.attribute(field_def.name)
38
+ name if klass.public_method_defined?(name) && !::GraphQL::Schema::Object.public_method_defined?(name)
39
+ end
40
+
41
+ def initialize(field_def, type_def, method_name)
42
+ super(field_def, type_def, nil)
43
+ @method_name = method_name
44
+ end
45
+
46
+ def define_resolver(target_type_class)
47
+ Lighthouse::GraphQL::ResolveInfo.request_lookahead(@field_def)
48
+ me = self
49
+ inner = capture_inner(target_type_class)
50
+
51
+ install(target_type_class, lambda do |obj, args, ctx|
52
+ me.send(:resolve_with_type, inner, obj, args, ctx)
53
+ end)
54
+ end
55
+
56
+ private
57
+
58
+ # The class is looked up on each call, so code reloading keeps working; when
59
+ # it no longer defines the method, the field is read as before. A parent that
60
+ # is not a record of the type's @model is reported, and the field resolves to nil.
61
+ def resolve_with_type(inner, obj, args, ctx)
62
+ klass = self.class.lookup(@type_def.graphql_name)
63
+ return inner.call(obj, args, ctx) unless klass&.public_method_defined?(@method_name)
64
+
65
+ model = Lighthouse::GraphQL::Directives::ModelDirective.declared_model(@type_def)
66
+ return wrong_parent(klass, model, obj, ctx) if model && !obj.is_a?(model)
67
+
68
+ info = Lighthouse::GraphQL::ResolveInfo.for(@field_def, @type_def, obj, args, ctx)
69
+ instance = Lighthouse::GraphQL::ResolveInfo.attach(klass.send(:new, obj, ctx), info)
70
+ kwargs = Lighthouse::Support::Arguments.prepare(@field_def, args, ctx)
71
+ kwargs.empty? ? instance.public_send(@method_name) : instance.public_send(@method_name, **kwargs)
72
+ end
73
+
74
+ def wrong_parent(klass, model, obj, ctx)
75
+ error = Lighthouse::DefinitionError.new(
76
+ "#{@type_def.graphql_name}.#{@field_def.name} resolves with #{klass}, but its parent is a #{obj.class}, not a #{model} (@model)."
77
+ )
78
+ Lighthouse::Config.handle_error(error, ctx)
79
+ nil
80
+ end
81
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @withCount(relation: "posts", scopes: [...]) — count a relation of the parent
6
+ # before the field resolves, as PHP Lighthouse's @withCount does: the count is not
7
+ # the field's value, it is kept on the parent as `<relation>_count`
8
+ # (`user.posts_count`) for whatever resolves the field to read. Every parent in
9
+ # the response is counted in one query.
10
+ #
11
+ # type User {
12
+ # activity: String! @withCount(relation: "posts") @method(name: "activity_label")
13
+ # postsCount: Int! @withCount(relation: "posts") # the default reader reads posts_count
14
+ # }
15
+ class Lighthouse::GraphQL::DirectiveResolvers::WithCountDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
16
+ def self.directive_name = 'withCount'
17
+
18
+ def self.definition
19
+ <<~GRAPHQL.chomp
20
+ """
21
+ Count a relation of the parent before the field resolves, for every parent at once,
22
+ and keep the count on it as `<relation>_count`.
23
+ """
24
+ directive @withCount(
25
+ "The relation to count."
26
+ relation: String!
27
+ "Model scopes that narrow the counted records."
28
+ scopes: [String!]
29
+ ) repeatable on FIELD_DEFINITION
30
+ GRAPHQL
31
+ end
32
+
33
+ def define_resolver(target_type_class)
34
+ inner = capture_inner(target_type_class)
35
+ relation = directive_arg('relation')
36
+ scopes = Array(directive_arg('scopes'))
37
+
38
+ install(target_type_class, lambda do |obj, args, ctx|
39
+ ctx.dataloader.with(Lighthouse::GraphQL::DataloaderSources::RelationCountsLoader, relation, scopes).load(obj) if obj.is_a?(::ActiveRecord::Base)
40
+ inner.call(obj, args, ctx)
41
+ end)
42
+ end
43
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @with(relation: "tasks", scopes: [...]) — eager-load a relation of the parent
6
+ # before the field resolves, as PHP Lighthouse's @with does: for a field computed
7
+ # from it (a @method, a type class method, the default reader of a method that
8
+ # reads it), so it is loaded once for every parent in the response, not once per
9
+ # parent. `relation` defaults to the field's (snake_case) name, and may be nested
10
+ # ("tasks.assignee"); `scopes` narrow the loaded records.
11
+ #
12
+ # type User {
13
+ # taskSummary: String! @with(relation: "tasks") @method(name: "task_summary")
14
+ # }
15
+ class Lighthouse::GraphQL::DirectiveResolvers::WithDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
16
+ def self.directive_name = 'with'
17
+
18
+ def self.definition
19
+ <<~GRAPHQL.chomp
20
+ """
21
+ Eager-load a relation of the parent before the field resolves, for every parent at once.
22
+ """
23
+ directive @with(
24
+ "The relation to load, nested with dots. Defaults to the field's name."
25
+ relation: String
26
+ "Model scopes that narrow the loaded records."
27
+ scopes: [String!]
28
+ ) repeatable on FIELD_DEFINITION
29
+ GRAPHQL
30
+ end
31
+
32
+ def define_resolver(target_type_class)
33
+ inner = capture_inner(target_type_class)
34
+ associations = Lighthouse::Support::Preload.associations(directive_arg('relation') || Lighthouse::Support::Naming.attribute(@field_def.name))
35
+ scopes = Array(directive_arg('scopes'))
36
+
37
+ install(target_type_class, lambda do |obj, args, ctx|
38
+ ctx.dataloader.with(Lighthouse::GraphQL::DataloaderSources::PreloadLoader, associations, scopes).load(obj) if obj.is_a?(::ActiveRecord::Base)
39
+ inner.call(obj, args, ctx)
40
+ end)
41
+ end
42
+ end