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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +139 -1
- data/README.md +8 -5
- data/docs/configuration.md +26 -2
- data/docs/directives.md +287 -4
- data/docs/generators.md +82 -0
- data/docs/getting-started.md +26 -30
- data/docs/relationships.md +42 -0
- data/docs/relay.md +137 -0
- data/docs/resolvers.md +261 -22
- data/docs/security.md +17 -0
- data/docs/types.md +219 -0
- data/docs/uploads.md +80 -0
- data/lib/generators/lighthouse/base_classes.rb +32 -49
- data/lib/generators/lighthouse/install/install_generator.rb +83 -24
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +14 -6
- data/lib/generators/lighthouse/install/templates/schema.rb.tt +10 -3
- data/lib/lighthouse/async_mutation_job.rb +17 -0
- data/lib/lighthouse/complexity.rb +5 -4
- data/lib/lighthouse/config.rb +13 -2
- data/lib/lighthouse/controller.rb +27 -9
- data/lib/lighthouse/dataloader_sources/preload_loader.rb +16 -0
- data/lib/lighthouse/dataloader_sources/relation_counts_loader.rb +46 -0
- data/lib/lighthouse/default_resolve_dispatcher.rb +26 -0
- data/lib/lighthouse/directive_resolvers/all_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/argument_binding.rb +61 -0
- data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +220 -0
- data/lib/lighthouse/directive_resolvers/association_resolvers.rb +2 -2
- data/lib/lighthouse/directive_resolvers/async_resolver.rb +90 -0
- data/lib/lighthouse/directive_resolvers/base.rb +63 -14
- data/lib/lighthouse/directive_resolvers/builder_applier.rb +89 -19
- data/lib/lighthouse/directive_resolvers/can_resolvers.rb +1 -1
- data/lib/lighthouse/directive_resolvers/convention_resolver.rb +2 -1
- data/lib/lighthouse/directive_resolvers/count_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/field_resolver.rb +35 -7
- data/lib/lighthouse/directive_resolvers/first_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +57 -0
- data/lib/lighthouse/directive_resolvers/lazy_load_resolver.rb +61 -0
- data/lib/lighthouse/directive_resolvers/limit_resolver.rb +59 -0
- data/lib/lighthouse/directive_resolvers/method_resolver.rb +38 -3
- data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/namespaced_resolver.rb +31 -0
- data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/resolution.rb +19 -4
- data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +2 -1
- data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +77 -0
- data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +81 -0
- data/lib/lighthouse/directive_resolvers/with_count_resolver.rb +43 -0
- data/lib/lighthouse/directive_resolvers/with_resolver.rb +42 -0
- data/lib/lighthouse/directives/arguments/more_filters.rb +215 -0
- data/lib/lighthouse/directives/definitions.rb +52 -0
- data/lib/lighthouse/directives/model_directive.rb +131 -0
- data/lib/lighthouse/directives/node_directive.rb +134 -0
- data/lib/lighthouse/directives/pagination_manipulator.rb +1 -1
- data/lib/lighthouse/directives/registry.rb +21 -21
- data/lib/lighthouse/directives/visibility_directive.rb +71 -0
- data/lib/lighthouse/enum_directive.rb +41 -0
- data/lib/lighthouse/field_resolver_stack.rb +53 -6
- data/lib/lighthouse/generated_resolvers.rb +13 -8
- data/lib/lighthouse/global_id.rb +31 -0
- data/lib/lighthouse/graphql/version.rb +1 -1
- data/lib/lighthouse/multipart_request.rb +43 -0
- data/lib/lighthouse/node_registry.rb +68 -0
- data/lib/lighthouse/pagination.rb +3 -0
- data/lib/lighthouse/rb_lighthouse.rb +33 -0
- data/lib/lighthouse/relation_counts.rb +29 -0
- data/lib/lighthouse/resolve_info.rb +80 -0
- data/lib/lighthouse/resolver_bases.rb +9 -2
- data/lib/lighthouse/resolver_dispatch.rb +36 -11
- data/lib/lighthouse/scalar_coercion.rb +72 -0
- data/lib/lighthouse/schema_cache.rb +2 -1
- data/lib/lighthouse/schema_generator.rb +11 -10
- data/lib/lighthouse/schema_implementation.rb +4 -8
- data/lib/lighthouse/support/environment.rb +13 -0
- data/lib/lighthouse/support/model_resolver.rb +7 -5
- data/lib/lighthouse/support/preload.rb +49 -0
- data/lib/lighthouse/type.rb +103 -0
- data/lib/lighthouse/type_resolution.rb +150 -0
- data/lib/lighthouse/upload.rb +26 -0
- data/lib/lighthouse/upload_store.rb +32 -0
- data/sig/lighthouse/directives.rbs +3 -2
- data/sig/lighthouse.rbs +18 -0
- 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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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::
|
|
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
|