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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +256 -1
- data/README.md +26 -15
- data/docs/caching.md +4 -3
- data/docs/configuration.md +16 -12
- data/docs/directives.md +294 -4
- data/docs/generators.md +84 -0
- data/docs/getting-started.md +93 -60
- data/docs/performance.md +2 -2
- data/docs/relationships.md +47 -4
- data/docs/relay.md +200 -0
- data/docs/resolvers.md +326 -34
- data/docs/security.md +73 -8
- data/docs/subscriptions.md +16 -9
- data/docs/types.md +218 -0
- data/docs/uploads.md +107 -0
- data/lib/generators/lighthouse/base_classes.rb +27 -49
- data/lib/generators/lighthouse/install/graphql_ruby_install.rb +113 -0
- data/lib/generators/lighthouse/install/install_generator.rb +125 -45
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +10 -8
- data/lib/lighthouse/async_mutation_job.rb +17 -0
- data/lib/lighthouse/caching.rb +9 -3
- data/lib/lighthouse/complexity.rb +5 -4
- data/lib/lighthouse/config.rb +12 -18
- data/lib/lighthouse/controller.rb +53 -12
- 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/cache_resolvers.rb +1 -1
- 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 +59 -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 +136 -0
- data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +87 -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 +56 -6
- data/lib/lighthouse/generated_resolvers.rb +13 -8
- data/lib/lighthouse/global_id.rb +57 -0
- data/lib/lighthouse/graphql/version.rb +1 -1
- data/lib/lighthouse/graphql_ruby_patches.rb +45 -0
- 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 +34 -0
- data/lib/lighthouse/relation_counts.rb +29 -0
- data/lib/lighthouse/resolve_info.rb +80 -0
- data/lib/lighthouse/resolver_bases.rb +25 -4
- data/lib/lighthouse/resolver_dispatch.rb +51 -11
- data/lib/lighthouse/root_types.rb +117 -0
- data/lib/lighthouse/scalar_coercion.rb +72 -0
- data/lib/lighthouse/schema_cache.rb +2 -1
- data/lib/lighthouse/schema_generator.rb +13 -11
- 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 +28 -0
- data/lib/lighthouse/upload_store.rb +57 -0
- data/sig/lighthouse/directives.rbs +3 -2
- data/sig/lighthouse.rbs +10 -2
- metadata +40 -4
- data/lib/generators/lighthouse/install/templates/schema.rb.tt +0 -22
- data/lib/generators/lighthouse/mutation_generator.rb +0 -31
- 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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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::
|
|
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
|
-
|
|
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,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
|