lighthouse-graphql 0.4.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +256 -1
  3. data/README.md +26 -15
  4. data/docs/caching.md +4 -3
  5. data/docs/configuration.md +16 -12
  6. data/docs/directives.md +294 -4
  7. data/docs/generators.md +84 -0
  8. data/docs/getting-started.md +93 -60
  9. data/docs/performance.md +2 -2
  10. data/docs/relationships.md +47 -4
  11. data/docs/relay.md +200 -0
  12. data/docs/resolvers.md +326 -34
  13. data/docs/security.md +73 -8
  14. data/docs/subscriptions.md +16 -9
  15. data/docs/types.md +218 -0
  16. data/docs/uploads.md +107 -0
  17. data/lib/generators/lighthouse/base_classes.rb +27 -49
  18. data/lib/generators/lighthouse/install/graphql_ruby_install.rb +113 -0
  19. data/lib/generators/lighthouse/install/install_generator.rb +125 -45
  20. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +10 -8
  21. data/lib/lighthouse/async_mutation_job.rb +17 -0
  22. data/lib/lighthouse/caching.rb +9 -3
  23. data/lib/lighthouse/complexity.rb +5 -4
  24. data/lib/lighthouse/config.rb +12 -18
  25. data/lib/lighthouse/controller.rb +53 -12
  26. data/lib/lighthouse/dataloader_sources/preload_loader.rb +16 -0
  27. data/lib/lighthouse/dataloader_sources/relation_counts_loader.rb +46 -0
  28. data/lib/lighthouse/default_resolve_dispatcher.rb +26 -0
  29. data/lib/lighthouse/directive_resolvers/all_resolver.rb +1 -1
  30. data/lib/lighthouse/directive_resolvers/argument_binding.rb +61 -0
  31. data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +220 -0
  32. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +2 -2
  33. data/lib/lighthouse/directive_resolvers/async_resolver.rb +90 -0
  34. data/lib/lighthouse/directive_resolvers/base.rb +63 -14
  35. data/lib/lighthouse/directive_resolvers/builder_applier.rb +89 -19
  36. data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +1 -1
  37. data/lib/lighthouse/directive_resolvers/can_resolvers.rb +1 -1
  38. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +2 -1
  39. data/lib/lighthouse/directive_resolvers/count_resolver.rb +1 -1
  40. data/lib/lighthouse/directive_resolvers/field_resolver.rb +35 -7
  41. data/lib/lighthouse/directive_resolvers/first_resolver.rb +1 -1
  42. data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +59 -0
  43. data/lib/lighthouse/directive_resolvers/lazy_load_resolver.rb +61 -0
  44. data/lib/lighthouse/directive_resolvers/limit_resolver.rb +59 -0
  45. data/lib/lighthouse/directive_resolvers/method_resolver.rb +38 -3
  46. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +1 -1
  47. data/lib/lighthouse/directive_resolvers/namespaced_resolver.rb +31 -0
  48. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +1 -1
  49. data/lib/lighthouse/directive_resolvers/resolution.rb +19 -4
  50. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +2 -1
  51. data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +136 -0
  52. data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +87 -0
  53. data/lib/lighthouse/directive_resolvers/with_count_resolver.rb +43 -0
  54. data/lib/lighthouse/directive_resolvers/with_resolver.rb +42 -0
  55. data/lib/lighthouse/directives/arguments/more_filters.rb +215 -0
  56. data/lib/lighthouse/directives/definitions.rb +52 -0
  57. data/lib/lighthouse/directives/model_directive.rb +131 -0
  58. data/lib/lighthouse/directives/node_directive.rb +134 -0
  59. data/lib/lighthouse/directives/pagination_manipulator.rb +1 -1
  60. data/lib/lighthouse/directives/registry.rb +21 -21
  61. data/lib/lighthouse/directives/visibility_directive.rb +71 -0
  62. data/lib/lighthouse/enum_directive.rb +41 -0
  63. data/lib/lighthouse/field_resolver_stack.rb +56 -6
  64. data/lib/lighthouse/generated_resolvers.rb +13 -8
  65. data/lib/lighthouse/global_id.rb +57 -0
  66. data/lib/lighthouse/graphql/version.rb +1 -1
  67. data/lib/lighthouse/graphql_ruby_patches.rb +45 -0
  68. data/lib/lighthouse/multipart_request.rb +43 -0
  69. data/lib/lighthouse/node_registry.rb +68 -0
  70. data/lib/lighthouse/pagination.rb +3 -0
  71. data/lib/lighthouse/rb_lighthouse.rb +34 -0
  72. data/lib/lighthouse/relation_counts.rb +29 -0
  73. data/lib/lighthouse/resolve_info.rb +80 -0
  74. data/lib/lighthouse/resolver_bases.rb +25 -4
  75. data/lib/lighthouse/resolver_dispatch.rb +51 -11
  76. data/lib/lighthouse/root_types.rb +117 -0
  77. data/lib/lighthouse/scalar_coercion.rb +72 -0
  78. data/lib/lighthouse/schema_cache.rb +2 -1
  79. data/lib/lighthouse/schema_generator.rb +13 -11
  80. data/lib/lighthouse/schema_implementation.rb +4 -8
  81. data/lib/lighthouse/support/environment.rb +13 -0
  82. data/lib/lighthouse/support/model_resolver.rb +7 -5
  83. data/lib/lighthouse/support/preload.rb +49 -0
  84. data/lib/lighthouse/type.rb +103 -0
  85. data/lib/lighthouse/type_resolution.rb +150 -0
  86. data/lib/lighthouse/upload.rb +28 -0
  87. data/lib/lighthouse/upload_store.rb +57 -0
  88. data/sig/lighthouse/directives.rbs +3 -2
  89. data/sig/lighthouse.rbs +10 -2
  90. metadata +40 -4
  91. data/lib/generators/lighthouse/install/templates/schema.rb.tt +0 -22
  92. data/lib/generators/lighthouse/mutation_generator.rb +0 -31
  93. data/lib/generators/lighthouse/query_generator.rb +0 -30
@@ -44,7 +44,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::CountDirectiveResolver < Lighthou
44
44
  args_h = arguments.respond_to?(:to_h) ? arguments.to_h : {}
45
45
  relation = apply_scopes(relation)
46
46
  relation = Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.apply(relation, @field_def.ast_node, args_h, ctx, self)
47
- relation = apply_where_filters(relation, args_h)
47
+ relation = apply_where_filters(relation, args_h, ctx)
48
48
  relation.count
49
49
  end
50
50
  end
@@ -4,21 +4,49 @@ require_relative 'base'
4
4
 
5
5
  class Lighthouse::GraphQL::DirectiveResolvers::FieldDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
6
6
  def self.directive_name = 'field'
7
- def self.definition = 'directive @field(resolver: String, class: String) on FIELD_DEFINITION'
8
7
 
9
- # On Query and Mutation the class is looked up in the queries / mutations
10
- # namespaces; on any other type, in Config.resolver_namespaces.
8
+ def self.definition
9
+ <<~GRAPHQL.chomp
10
+ """
11
+ Assign a resolver to a field: a class, or a class and a method separated by `@`
12
+ ("Statistics@users_total"). Without a method, the class resolves, or is called.
13
+ """
14
+ directive @field(resolver: String, class: String) on FIELD_DEFINITION
15
+ GRAPHQL
16
+ end
17
+
18
+ # ["Statistics", "users_total"] for "Statistics@users_total"; [name, nil] without `@`.
19
+ def self.reference(resolver)
20
+ class_name, method = resolver.to_s.split('@', 2)
21
+ [class_name, method.presence]
22
+ end
23
+
24
+ # The class @field names. On Query and Mutation (`kind` :queries / :mutations)
25
+ # it is looked up in that kind's namespaces, nested names included
26
+ # (`Feeds::Home` is Resolvers::Feeds::Home), then as written. On any other
27
+ # type, in the resolvers namespace first (`Posts::TagsCount` is
28
+ # Resolvers::Posts::TagsCount), then in Config.resolver_namespaces. Looked up
29
+ # at call time, so code reloading keeps working.
30
+ def self.lookup(class_name, kind)
31
+ resolver = Lighthouse::Support::ModelResolver
32
+ return resolver.constantize(class_name, namespaces: Lighthouse::Config.namespaces[kind], nested: true) if kind
33
+
34
+ resolver.constantize(class_name, namespaces: Lighthouse::Config.namespaces[:queries], nested: true, fallback: false) ||
35
+ resolver.constantize(class_name, namespaces: Lighthouse::Config.resolver_namespaces)
36
+ end
37
+
11
38
  def define_resolver(target_type_class)
39
+ Lighthouse::GraphQL::ResolveInfo.request_lookahead(@field_def)
12
40
  field_def = @field_def
13
- class_name = extract_directive_arg('resolver') || extract_directive_arg('class')
41
+ class_name, method = self.class.reference(extract_directive_arg('resolver') || extract_directive_arg('class'))
14
42
  kind = root_kind(target_type_class)
15
43
 
16
44
  install(target_type_class, lambda do |obj, args, ctx|
17
- namespaces = kind ? Lighthouse::Config.namespaces[kind] : Lighthouse::Config.resolver_namespaces
18
- klass = Lighthouse::Support::ModelResolver.constantize(class_name, namespaces: namespaces)
45
+ klass = Lighthouse::GraphQL::DirectiveResolvers::FieldDirectiveResolver.lookup(class_name, kind)
19
46
  next nil unless klass
20
47
 
21
- Lighthouse::GraphQL::ResolverDispatch.call(klass, field_def, obj, args, ctx)
48
+ info = Lighthouse::GraphQL::ResolveInfo.for(field_def, target_type_class, obj, args, ctx)
49
+ Lighthouse::GraphQL::ResolverDispatch.call(klass, info, method: method)
22
50
  end)
23
51
  end
24
52
 
@@ -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,59 @@
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
+ next if id.nil?
55
+
56
+ Lighthouse::GraphQL::GlobalId.encode_field(target_type_class, obj, id, ctx) || Lighthouse::Config.global_id.encode(type_name, id)
57
+ end)
58
+ end
59
+ 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,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/duration'
4
+ require 'active_support/notifications'
5
+ require_relative 'base'
6
+
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.
29
+ #
30
+ # type Mutation {
31
+ # login(email: String!, password: String!): User @throttle(maxAttempts: 5)
32
+ # search(term: String!): [Post!]! @throttle(maxAttempts: 30, decayMinutes: 0.5, by: "current_user.id")
33
+ # }
34
+ class Lighthouse::GraphQL::DirectiveResolvers::ThrottleDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
35
+ def self.directive_name = 'throttle'
36
+
37
+ def self.definition
38
+ <<~GRAPHQL.chomp
39
+ """
40
+ Sets rate limit to access the field, as Rails' rate_limit does for an action.
41
+ """
42
+ directive @throttle(
43
+ "As rate_limit's name: fields, and the controller's own rate_limit, of the same name count together. Defaults to the field."
44
+ name: String
45
+ "Maximum number of attempts in a specified time interval."
46
+ maxAttempts: Int = 60
47
+ "Time in minutes to reset attempts."
48
+ decayMinutes: Float = 1.0
49
+ "As rate_limit's scope: what the count is shared across. Defaults to the controller's path."
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
55
+ ) on FIELD_DEFINITION
56
+ GRAPHQL
57
+ end
58
+
59
+ def define_resolver(target_type_class)
60
+ me = self
61
+ inner = capture_inner(target_type_class)
62
+ location = "#{target_type_class.graphql_name}.#{@field_def.name}"
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
+ }
67
+
68
+ install(target_type_class, lambda do |obj, args, ctx|
69
+ me.hit!(limit, ctx, location)
70
+ inner.call(obj, args, ctx)
71
+ end)
72
+ end
73
+
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]
81
+ end
82
+
83
+ # A :null_store keeps no count, so the limit never applies: say so, once per field.
84
+ def self.warn_null_store(location)
85
+ @warned ||= Set.new
86
+ return unless @warned.add?(location)
87
+
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.'
90
+ defined?(Rails) && Rails.logger ? Rails.logger.warn(message) : warn(message)
91
+ end
92
+
93
+ private
94
+
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
133
+
134
+ controller.send(with)
135
+ end
136
+ end
@@ -0,0 +1,87 @@
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 the
14
+ # root types too (Types::QueryType#latest_posts for Query.latestPosts, before
15
+ # the conventional Resolvers::LatestPosts).
16
+ class Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
17
+ # The class of the SDL type `type_name` (`Types::PostType` for `Post`) in
18
+ # Config.namespaces[:types], when it includes Lighthouse::GraphQL::Type; nil
19
+ # otherwise, so a type class that does not opt in is left alone. It must
20
+ # inherit from the schema's base_types[:object] (in `bases`, ResolverBases)
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
+
25
+ klass = lookup(type_name)
26
+ return unless klass
27
+
28
+ Lighthouse::GraphQL::ResolverBases.check!(klass, bases.dig(:types, :object), "the #{type_name} type")
29
+ klass
30
+ end
31
+
32
+ def self.lookup(type_name)
33
+ Lighthouse::GraphQL::Type.class_for(type_name, :object)
34
+ end
35
+
36
+ # The method of `klass` that resolves `field_def` (`reading_time` for
37
+ # `readingTime`), or nil. Methods every GraphQL::Schema::Object has
38
+ # (`object`, `context`, Object's own) never count.
39
+ def self.method_for(klass, field_def)
40
+ name = Lighthouse::Support::Naming.attribute(field_def.name)
41
+ name if klass.public_method_defined?(name) && !::GraphQL::Schema::Object.public_method_defined?(name)
42
+ end
43
+
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)
47
+ super(field_def, type_def, nil)
48
+ @method_name = method_name
49
+ @root_class = root_class
50
+ end
51
+
52
+ def define_resolver(target_type_class)
53
+ Lighthouse::GraphQL::ResolveInfo.request_lookahead(@field_def)
54
+ me = self
55
+ inner = capture_inner(target_type_class)
56
+
57
+ install(target_type_class, lambda do |obj, args, ctx|
58
+ me.send(:resolve_with_type, inner, obj, args, ctx)
59
+ end)
60
+ end
61
+
62
+ private
63
+
64
+ # The class is looked up on each call, so code reloading keeps working; when
65
+ # it no longer defines the method, the field is read as before. A parent that
66
+ # is not a record of the type's @model is reported, and the field resolves to nil.
67
+ def resolve_with_type(inner, obj, args, ctx)
68
+ klass = @root_class || self.class.lookup(@type_def.graphql_name)
69
+ return inner.call(obj, args, ctx) unless klass&.public_method_defined?(@method_name)
70
+
71
+ model = Lighthouse::GraphQL::Directives::ModelDirective.declared_model(@type_def)
72
+ return wrong_parent(klass, model, obj, ctx) if model && !obj.is_a?(model)
73
+
74
+ info = Lighthouse::GraphQL::ResolveInfo.for(@field_def, @type_def, obj, args, ctx)
75
+ instance = Lighthouse::GraphQL::ResolveInfo.attach(klass.send(:new, obj, ctx), info)
76
+ kwargs = Lighthouse::Support::Arguments.prepare(@field_def, args, ctx)
77
+ kwargs.empty? ? instance.public_send(@method_name) : instance.public_send(@method_name, **kwargs)
78
+ end
79
+
80
+ def wrong_parent(klass, model, obj, ctx)
81
+ error = Lighthouse::DefinitionError.new(
82
+ "#{@type_def.graphql_name}.#{@field_def.name} resolves with #{klass}, but its parent is a #{obj.class}, not a #{model} (@model)."
83
+ )
84
+ Lighthouse::Config.handle_error(error, ctx)
85
+ nil
86
+ end
87
+ end