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