lighthouse-graphql 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -1
  3. data/README.md +8 -5
  4. data/docs/configuration.md +26 -2
  5. data/docs/directives.md +287 -4
  6. data/docs/generators.md +82 -0
  7. data/docs/getting-started.md +26 -30
  8. data/docs/relationships.md +42 -0
  9. data/docs/relay.md +137 -0
  10. data/docs/resolvers.md +261 -22
  11. data/docs/security.md +17 -0
  12. data/docs/types.md +219 -0
  13. data/docs/uploads.md +80 -0
  14. data/lib/generators/lighthouse/base_classes.rb +32 -49
  15. data/lib/generators/lighthouse/install/install_generator.rb +83 -24
  16. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +14 -6
  17. data/lib/generators/lighthouse/install/templates/schema.rb.tt +10 -3
  18. data/lib/lighthouse/async_mutation_job.rb +17 -0
  19. data/lib/lighthouse/complexity.rb +5 -4
  20. data/lib/lighthouse/config.rb +13 -2
  21. data/lib/lighthouse/controller.rb +27 -9
  22. data/lib/lighthouse/dataloader_sources/preload_loader.rb +16 -0
  23. data/lib/lighthouse/dataloader_sources/relation_counts_loader.rb +46 -0
  24. data/lib/lighthouse/default_resolve_dispatcher.rb +26 -0
  25. data/lib/lighthouse/directive_resolvers/all_resolver.rb +1 -1
  26. data/lib/lighthouse/directive_resolvers/argument_binding.rb +61 -0
  27. data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +220 -0
  28. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +2 -2
  29. data/lib/lighthouse/directive_resolvers/async_resolver.rb +90 -0
  30. data/lib/lighthouse/directive_resolvers/base.rb +63 -14
  31. data/lib/lighthouse/directive_resolvers/builder_applier.rb +89 -19
  32. data/lib/lighthouse/directive_resolvers/can_resolvers.rb +1 -1
  33. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +2 -1
  34. data/lib/lighthouse/directive_resolvers/count_resolver.rb +1 -1
  35. data/lib/lighthouse/directive_resolvers/field_resolver.rb +35 -7
  36. data/lib/lighthouse/directive_resolvers/first_resolver.rb +1 -1
  37. data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +57 -0
  38. data/lib/lighthouse/directive_resolvers/lazy_load_resolver.rb +61 -0
  39. data/lib/lighthouse/directive_resolvers/limit_resolver.rb +59 -0
  40. data/lib/lighthouse/directive_resolvers/method_resolver.rb +38 -3
  41. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +1 -1
  42. data/lib/lighthouse/directive_resolvers/namespaced_resolver.rb +31 -0
  43. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +1 -1
  44. data/lib/lighthouse/directive_resolvers/resolution.rb +19 -4
  45. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +2 -1
  46. data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +77 -0
  47. data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +81 -0
  48. data/lib/lighthouse/directive_resolvers/with_count_resolver.rb +43 -0
  49. data/lib/lighthouse/directive_resolvers/with_resolver.rb +42 -0
  50. data/lib/lighthouse/directives/arguments/more_filters.rb +215 -0
  51. data/lib/lighthouse/directives/definitions.rb +52 -0
  52. data/lib/lighthouse/directives/model_directive.rb +131 -0
  53. data/lib/lighthouse/directives/node_directive.rb +134 -0
  54. data/lib/lighthouse/directives/pagination_manipulator.rb +1 -1
  55. data/lib/lighthouse/directives/registry.rb +21 -21
  56. data/lib/lighthouse/directives/visibility_directive.rb +71 -0
  57. data/lib/lighthouse/enum_directive.rb +41 -0
  58. data/lib/lighthouse/field_resolver_stack.rb +53 -6
  59. data/lib/lighthouse/generated_resolvers.rb +13 -8
  60. data/lib/lighthouse/global_id.rb +31 -0
  61. data/lib/lighthouse/graphql/version.rb +1 -1
  62. data/lib/lighthouse/multipart_request.rb +43 -0
  63. data/lib/lighthouse/node_registry.rb +68 -0
  64. data/lib/lighthouse/pagination.rb +3 -0
  65. data/lib/lighthouse/rb_lighthouse.rb +33 -0
  66. data/lib/lighthouse/relation_counts.rb +29 -0
  67. data/lib/lighthouse/resolve_info.rb +80 -0
  68. data/lib/lighthouse/resolver_bases.rb +9 -2
  69. data/lib/lighthouse/resolver_dispatch.rb +36 -11
  70. data/lib/lighthouse/scalar_coercion.rb +72 -0
  71. data/lib/lighthouse/schema_cache.rb +2 -1
  72. data/lib/lighthouse/schema_generator.rb +11 -10
  73. data/lib/lighthouse/schema_implementation.rb +4 -8
  74. data/lib/lighthouse/support/environment.rb +13 -0
  75. data/lib/lighthouse/support/model_resolver.rb +7 -5
  76. data/lib/lighthouse/support/preload.rb +49 -0
  77. data/lib/lighthouse/type.rb +103 -0
  78. data/lib/lighthouse/type_resolution.rb +150 -0
  79. data/lib/lighthouse/upload.rb +26 -0
  80. data/lib/lighthouse/upload_store.rb +32 -0
  81. data/sig/lighthouse/directives.rbs +3 -2
  82. data/sig/lighthouse.rbs +18 -0
  83. metadata +38 -1
@@ -4,7 +4,12 @@
4
4
  # namespaces follow graphql-ruby's contract — GraphQL::Schema::Resolver and
5
5
  # GraphQL::Schema::Mutation: `resolve(**args)` with `object` and `context`,
6
6
  # plus `ready?` / `authorized?` — exactly as in a graphql-ruby schema.
7
- # @field also accepts a plain callable, `call(object, args, context)`.
7
+ # @field also accepts a plain callable, `call(object, args, context)`, which
8
+ # gets the ResolveInfo as a fourth argument when it takes one.
9
+ #
10
+ # `method:` is the method after `@` in `@field(resolver: "Class@method")`: it
11
+ # replaces `resolve` (keeping `ready?` / `authorized?`) on a resolver class, and
12
+ # `call` on a plain class, as a class method or else an instance method.
8
13
  module Lighthouse::GraphQL::ResolverDispatch
9
14
  module_function
10
15
 
@@ -12,15 +17,35 @@ module Lighthouse::GraphQL::ResolverDispatch
12
17
  klass.is_a?(Class) && klass < ::GraphQL::Schema::Resolver
13
18
  end
14
19
 
15
- def call(klass, field_def, obj, args, ctx)
16
- if graphql_resolver?(klass)
17
- kwargs = Lighthouse::Support::Arguments.prepare(field_def, args, ctx)
18
- klass.new(object: obj, context: ctx, field: field_def).resolve_with_support(**kwargs)
19
- elsif klass.respond_to?(:call)
20
- klass.call(obj, args, ctx)
21
- else
22
- instance = klass.new
23
- instance.respond_to?(:call) ? instance.call(obj, args, ctx) : instance.resolve(obj, args, ctx)
24
- end
20
+ # `info`: the ResolveInfo of the field being resolved, which carries its
21
+ # object, arguments and context.
22
+ def call(klass, info, method: nil)
23
+ return call_resolver(klass, info, method) if graphql_resolver?(klass)
24
+
25
+ receiver = callable_receiver(klass, method || :call)
26
+ callable = receiver.method(method || (receiver.respond_to?(:call) ? :call : :resolve))
27
+ args = [info.object, info.arguments, info.context]
28
+ takes_info?(receiver, callable) ? callable.call(*args, info) : callable.call(*args)
29
+ end
30
+
31
+ def call_resolver(klass, info, method)
32
+ kwargs = Lighthouse::Support::Arguments.prepare(info.field, info.arguments, info.context)
33
+ instance = Lighthouse::GraphQL::ResolveInfo.attach(klass.new(object: info.object, context: info.context, field: info.field), info)
34
+ instance.define_singleton_method(:resolve) { |**values| public_send(method, **values) } if method
35
+ instance.resolve_with_support(**kwargs)
36
+ end
37
+
38
+ # The class itself when it answers `name` as a class method, else an instance.
39
+ def callable_receiver(klass, name)
40
+ klass.respond_to?(name) ? klass : klass.new
41
+ end
42
+
43
+ POSITIONAL = %i[req opt].freeze
44
+
45
+ # Does the callable take a fourth argument? A lambda's own parameters count,
46
+ # not Proc#call's (*args).
47
+ def takes_info?(receiver, callable)
48
+ params = receiver.is_a?(Proc) && callable.name == :call ? receiver.parameters : callable.parameters
49
+ params.any? { |type, _| type == :rest } || params.count { |type, _| POSITIONAL.include?(type) } >= 4
25
50
  end
26
51
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # How the values of an SDL scalar (`scalar DateTime`) are read from a query and
4
+ # written to a response, as a graphql-ruby scalar class does it with
5
+ # `coerce_input(value, context)` and `coerce_result(value, context)`. The class
6
+ # is, in order:
7
+ #
8
+ # 1. the one `@scalar(class: "Name")` on the scalar names, looked up in
9
+ # Config.namespaces[:types] (nested names too) and then as written. PHP
10
+ # Lighthouse's directive.
11
+ # 2. Its type class, Types::<Name>Type including Lighthouse::GraphQL::Type: a
12
+ # GraphQL::Schema::Scalar, as graphql-ruby's `rails g graphql:scalar` writes it.
13
+ # 3. Lighthouse's own, for `scalar Upload` (Lighthouse::GraphQL::Upload).
14
+ #
15
+ # A scalar with none passes its values through unchanged. A class raising
16
+ # GraphQL::CoercionError from coerce_input (or returning nil for a value) makes
17
+ # the argument invalid, with that message, as in graphql-ruby.
18
+ module Lighthouse::GraphQL::ScalarCoercion
19
+ DEFINITION = <<~GRAPHQL.chomp
20
+ """
21
+ The class that reads and writes this scalar's values: coerce_input(value, context) and
22
+ coerce_result(value, context), as a GraphQL::Schema::Scalar.
23
+ """
24
+ directive @scalar(class: String!) on SCALAR
25
+ GRAPHQL
26
+
27
+ module_function
28
+
29
+ # Give every SDL scalar that has a class its coercion. The class is looked up
30
+ # again on each value, so code reloading keeps working; `bases` is the
31
+ # schema's base_types[:scalar], which a type class must inherit from.
32
+ def apply(schema, base = nil)
33
+ schema.types.each_value do |type|
34
+ next unless type.kind.scalar? && type.ast_node
35
+
36
+ klass = class_for(type, base)
37
+ install(type, klass) if klass
38
+ end
39
+ end
40
+
41
+ # Scalars Lighthouse brings a class for, when nothing else names one.
42
+ BUILT_IN = { 'Upload' => 'Lighthouse::GraphQL::Upload' }.freeze
43
+
44
+ def class_for(scalar_type, base = nil)
45
+ directive_class(scalar_type) || type_class(scalar_type, base) || BUILT_IN[scalar_type.graphql_name]&.constantize
46
+ end
47
+
48
+ def directive_class(scalar_type)
49
+ directive = (scalar_type.ast_node.directives || []).find { |d| d.name == 'scalar' }
50
+ name = directive && Lighthouse::Support::DirectiveArgs.literal(directive, 'class')
51
+ return unless name
52
+
53
+ klass = Lighthouse::Support::ModelResolver.constantize(name, namespaces: Lighthouse::Config.namespaces[:types], nested: true)
54
+ return klass if klass.respond_to?(:coerce_input) && klass.respond_to?(:coerce_result)
55
+
56
+ raise Lighthouse::DefinitionError,
57
+ "@scalar(class: #{name.inspect}) on #{scalar_type.graphql_name} names no class with coerce_input(value, context) " \
58
+ 'and coerce_result(value, context).'
59
+ end
60
+
61
+ def type_class(scalar_type, base)
62
+ klass = Lighthouse::GraphQL::Type.class_for(scalar_type.graphql_name, :scalar)
63
+ Lighthouse::GraphQL::ResolverBases.check!(klass, base, "the #{scalar_type.graphql_name} scalar") if klass
64
+ klass
65
+ end
66
+
67
+ def install(scalar_type, klass)
68
+ current = -> { klass.name ? klass.name.constantize : klass }
69
+ scalar_type.define_singleton_method(:coerce_input) { |value, ctx| current.call.coerce_input(value, ctx) }
70
+ scalar_type.define_singleton_method(:coerce_result) { |value, ctx| current.call.coerce_result(value, ctx) }
71
+ end
72
+ end
@@ -72,7 +72,8 @@ module Lighthouse::GraphQL::SchemaCache
72
72
  registry.directive_definitions.sort,
73
73
  registry.type_definitions.sort,
74
74
  manipulators,
75
- Lighthouse::Config.pagination_default_count
75
+ Lighthouse::Config.pagination_default_count,
76
+ Lighthouse::Support::Environment.current # @hide / @show
76
77
  ].inspect
77
78
  end
78
79
 
@@ -15,8 +15,8 @@ module Lighthouse::GraphQL::SchemaGenerator
15
15
  obj.respond_to?(field_name) ? obj.public_send(field_name) : nil
16
16
  end
17
17
 
18
- def resolve_type(_abstract_type, _obj, _ctx)
19
- nil
18
+ def resolve_type(abstract_type, obj, ctx)
19
+ Lighthouse::GraphQL::TypeResolution.resolve(ctx.respond_to?(:schema) && ctx.schema ? ctx.schema : schema, abstract_type, obj, ctx)
20
20
  end
21
21
 
22
22
  def coerce_input(_type, value, _ctx)
@@ -139,7 +139,9 @@ module Lighthouse::GraphQL::SchemaGenerator
139
139
  # Apply object-level federation directives to generated types so the
140
140
  # Apollo Federation helper can detect entities and add _entities.
141
141
  Lighthouse::GraphQL::FederationDirectives.apply(type)
142
- type.fields.each_value { |field_def| process_field(type, field_def, root_kinds[type.graphql_name], bases) }
142
+ root_kind = root_kinds[type.graphql_name]
143
+ type_class = root_kind ? nil : Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver.type_class(type.graphql_name, bases)
144
+ type.fields.each_value { |field_def| process_field(type, field_def, root_kind, bases, type_class) }
143
145
  end
144
146
 
145
147
  # @complexity, and paginated fields, give graphql-ruby's complexity analysis their cost.
@@ -151,6 +153,9 @@ module Lighthouse::GraphQL::SchemaGenerator
151
153
  # Every @broadcast must name a field of the Subscription type.
152
154
  Lighthouse::GraphQL::Broadcasting.validate!(schema)
153
155
 
156
+ # Unions, interfaces and scalars get the classes that resolve and coerce them.
157
+ Lighthouse::GraphQL::Type.apply(schema, bases.fetch(:types, {}))
158
+
154
159
  # return schema for convenience
155
160
  schema
156
161
  end
@@ -163,10 +168,11 @@ module Lighthouse::GraphQL::SchemaGenerator
163
168
 
164
169
  # A field's generated resolvers, then the runtime hooks that make
165
170
  # graphql-ruby reach them.
166
- def process_field(type, field_def, root_kind, bases = {})
171
+ def process_field(type, field_def, root_kind, bases = {}, type_class = nil)
167
172
  return unless field_def.ast_node
168
173
 
169
- apply_field_resolvers(type, field_def, field_def.ast_node.directives || [], root_kind, bases)
174
+ # Installed in resolution order (see FieldResolverStack).
175
+ Lighthouse::GraphQL::FieldResolverStack.apply(type, field_def, root_kind, bases: bases, type_class: type_class)
170
176
  Lighthouse::GraphQL::GeneratedResolvers.route_runtime_resolver(type, field_def)
171
177
  ensure_connection_extension(field_def)
172
178
  end
@@ -187,11 +193,6 @@ module Lighthouse::GraphQL::SchemaGenerator
187
193
  # ignore failures during post-processing
188
194
  end
189
195
 
190
- # A field's resolvers, installed in resolution order (see FieldResolverStack).
191
- def apply_field_resolvers(type, field_def, directives, root_kind, bases = {})
192
- Lighthouse::GraphQL::FieldResolverStack.apply(type, field_def, directives, root_kind, bases: bases)
193
- end
194
-
195
196
  # Scan SDL string for list-typed fields that carry relationship directives
196
197
  # and produce Connection/Edge types where missing. This is a lightweight
197
198
  # transformation that mirrors Lighthouse behavior: clients can declare
@@ -27,14 +27,10 @@ class SchemaImplementation
27
27
  read_attribute(obj, field_name)
28
28
  end
29
29
 
30
- def resolve_type(abstract_type, _obj, _ctx)
31
- # Try to resolve to a Ruby type class under Types::<Name>Type
32
- type_name = abstract_type.respond_to?(:name) ? abstract_type.name : abstract_type.to_s
33
- begin
34
- Object.const_get("Types::#{type_name}Type")
35
- rescue StandardError
36
- nil
37
- end
30
+ # The object type of a union's or interface's value (see TypeResolution).
31
+ def resolve_type(abstract_type, obj, ctx)
32
+ schema = ctx.respond_to?(:schema) && ctx.schema ? ctx.schema : @schema
33
+ Lighthouse::GraphQL::TypeResolution.resolve(schema, abstract_type, obj, ctx)
38
34
  end
39
35
 
40
36
  def coerce_input(_type, value, _ctx)
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The application's environment, for @hide and @show: Rails.env, else
4
+ # RAILS_ENV or RACK_ENV, else "development".
5
+ module Lighthouse::Support::Environment
6
+ module_function
7
+
8
+ def current
9
+ return Rails.env.to_s if defined?(Rails) && Rails.respond_to?(:env)
10
+
11
+ ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development'
12
+ end
13
+ end
@@ -26,14 +26,16 @@ module Lighthouse::Support::ModelResolver
26
26
  # `namespaces:` overrides the configured resolver_namespaces, e.g. with
27
27
  # Config.namespaces[:builders] to look a builder class up.
28
28
  # `fallback: false` skips the bare name, so `Resolvers::User` is never
29
- # mistaken for the `User` model.
30
- def constantize(name, namespaces: self.namespaces, fallback: true)
29
+ # mistaken for the `User` model. `nested: true` tries the namespaces before a
30
+ # name that already contains `::` too, so `Feeds::Home` is found as
31
+ # `Resolvers::Feeds::Home`; otherwise such a name is used as-is.
32
+ def constantize(name, namespaces: self.namespaces, fallback: true, nested: false)
31
33
  return nil unless name
32
34
 
33
35
  raw = name.to_s.strip
34
36
  return nil if raw.empty?
35
37
 
36
- candidates_for(raw, namespaces, fallback: fallback).each do |candidate|
38
+ candidates_for(raw, namespaces, fallback: fallback, nested: nested).each do |candidate|
37
39
  klass = try_constantize(candidate)
38
40
  return klass if klass
39
41
  end
@@ -41,8 +43,8 @@ module Lighthouse::Support::ModelResolver
41
43
  nil
42
44
  end
43
45
 
44
- def candidates_for(raw, namespaces = self.namespaces, fallback: true)
45
- if raw.include?('::')
46
+ def candidates_for(raw, namespaces = self.namespaces, fallback: true, nested: false)
47
+ if raw.include?('::') && !nested
46
48
  [raw]
47
49
  else
48
50
  Array(namespaces).map { |ns| "#{ns}::#{raw}" } + (fallback ? [raw] : [])
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Eager loading for @with and @lazyLoad: ActiveRecord's preloader, over records
4
+ # of any classes, with relation names as PHP Lighthouse writes them
5
+ # ("comments.author" is comments, and each comment's author).
6
+ module Lighthouse::Support::Preload
7
+ module_function
8
+
9
+ # ["comments.author", "tags"] -> [{ comments: :author }, :tags]
10
+ def associations(names)
11
+ Array(names).map do |name|
12
+ *path, last = name.to_s.split('.').map(&:to_sym)
13
+ path.reverse.reduce(last) { |nested, segment| { segment => nested } }
14
+ end
15
+ end
16
+
17
+ # Load `associations` into `records`, grouped by class; a class that has no
18
+ # such association is left alone. `scopes` (model scope names) narrow the
19
+ # loaded records, as Laravel's constrained eager loads do.
20
+ def records(records, associations, scopes = [])
21
+ records.grep(::ActiveRecord::Base).group_by(&:class).each do |klass, group|
22
+ own = associations.select { |association| klass.reflect_on_association(root(association)) }
23
+ own.each { |association| preload(klass, group, association, scopes) }
24
+ end
25
+ records
26
+ end
27
+
28
+ def root(association)
29
+ association.is_a?(Hash) ? association.keys.first : association
30
+ end
31
+
32
+ def preload(klass, records, association, scopes)
33
+ scope = scoped(klass, association, scopes)
34
+ preloader = ::ActiveRecord::Associations::Preloader
35
+ if preloader.method_defined?(:call)
36
+ preloader.new(records: records, associations: association, scope: scope).call # Rails >= 7
37
+ else
38
+ preloader.new.preload(records, association, scope) # Rails 6
39
+ end
40
+ end
41
+
42
+ # The scopes applied to the association's model, or nil for none.
43
+ def scoped(klass, association, scopes)
44
+ return if scopes.blank?
45
+
46
+ target = klass.reflect_on_association(root(association)).klass
47
+ scopes.reduce(target.all) { |relation, scope| relation.public_send(scope) }
48
+ end
49
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ # Hands a graphql-ruby type class to Lighthouse, as `include
6
+ # Lighthouse::GraphQL::Subscription` does a subscription. The class keeps
7
+ # graphql-ruby's folder structure and naming (app/graphql/types/post_type.rb,
8
+ # Types::PostType for the SDL's Post type) and base classes, and does for the
9
+ # SDL type of its name what it does in a graphql-ruby schema:
10
+ #
11
+ # class Types::PostType < Types::BaseObject # an object type
12
+ # include Lighthouse::GraphQL::Type
13
+ #
14
+ # def reading_time = { words: object.body.to_s.split.size }
15
+ # end
16
+ #
17
+ # class Types::SearchResultType < Types::BaseUnion # a union
18
+ # include Lighthouse::GraphQL::Type
19
+ #
20
+ # def self.resolve_type(object, context) = object.is_a?(Post) ? 'Post' : 'User'
21
+ # end
22
+ #
23
+ # module Types::NamedType # an interface
24
+ # include Types::BaseInterface
25
+ # include Lighthouse::GraphQL::Type
26
+ #
27
+ # definition_methods do
28
+ # def resolve_type(object, context) = ...
29
+ # end
30
+ # end
31
+ #
32
+ # class Types::DateTimeType < Types::BaseScalar # a scalar
33
+ # include Lighthouse::GraphQL::Type
34
+ #
35
+ # def self.coerce_input(value, context) = Time.iso8601(value)
36
+ # def self.coerce_result(value, context) = value.iso8601
37
+ # end
38
+ #
39
+ # An object type's methods resolve its fields that have no resolving
40
+ # directive, with graphql-ruby's `object` (the parent), `context`, the
41
+ # `dataload*` helpers, and the field's arguments as snake_case keywords. A
42
+ # union's or interface's `resolve_type` picks the object type of a value (a
43
+ # type name, or a type class such as Types::PostType). A scalar's
44
+ # `coerce_input` / `coerce_result` convert its values. The SDL still declares
45
+ # the types and their fields: the class only lends its behaviour, so `field`
46
+ # declarations in it are ignored, and an object type's `authorized?` does not run.
47
+ #
48
+ # The model an object type resolves is the SDL's to say, with @model(class:).
49
+ #
50
+ # A Types::<Name>Type that does not include the concern is not Lighthouse's,
51
+ # and is left alone.
52
+ module Lighthouse::GraphQL::Type
53
+ extend ActiveSupport::Concern
54
+
55
+ # The kinds of graphql-ruby class the concern can be included in.
56
+ KINDS = {
57
+ object: ::GraphQL::Schema::Object,
58
+ union: ::GraphQL::Schema::Union,
59
+ scalar: ::GraphQL::Schema::Scalar,
60
+ interface: ::GraphQL::Schema::Interface
61
+ }.freeze
62
+
63
+ # :object, :union, :scalar or :interface for a graphql-ruby type class or
64
+ # interface module; nil for anything else.
65
+ def self.kind_of(mod)
66
+ return :interface if !mod.is_a?(Class) && mod.is_a?(Module) && mod.include?(KINDS[:interface])
67
+ return unless mod.is_a?(Class)
68
+
69
+ %i[object union scalar].find { |kind| mod < KINDS[kind] }
70
+ end
71
+
72
+ # The class of the SDL type `type_name` (`Types::PostType` for `Post`) in
73
+ # Config.namespaces[:types], when it includes the concern; nil otherwise. The
74
+ # class must be of the same `kind` as the SDL type.
75
+ def self.class_for(type_name, kind)
76
+ klass = Lighthouse::Support::ModelResolver.constantize("#{type_name}Type", namespaces: Lighthouse::Config.namespaces[:types],
77
+ fallback: false)
78
+ return unless klass.is_a?(Module) && klass.include?(self) && kind_of(klass)
79
+ return klass if kind_of(klass) == kind
80
+
81
+ raise Lighthouse::DefinitionError, "#{klass} is a type class of kind #{kind_of(klass)}, but #{type_name} is of kind #{kind} in the schema."
82
+ end
83
+
84
+ # What the SDL's types take from their classes and directives, once the
85
+ # schema is built: every @model names a model (ModelDirective), every @union /
86
+ # @interface class exists and every type class matches its kind and base
87
+ # (TypeResolution), and every scalar with a class reads and writes its values
88
+ # through it (ScalarCoercion). `bases`: the schema's base_types, by kind.
89
+ def self.apply(schema, bases = {})
90
+ Lighthouse::GraphQL::Directives::ModelDirective.validate!(schema)
91
+ Lighthouse::GraphQL::TypeResolution.validate!(schema, bases)
92
+ Lighthouse::GraphQL::ScalarCoercion.apply(schema, bases[:scalar])
93
+ Lighthouse::GraphQL::EnumDirective.apply(schema)
94
+ end
95
+
96
+ included do
97
+ unless Lighthouse::GraphQL::Type.kind_of(self)
98
+ raise Lighthouse::DefinitionError,
99
+ "#{self} includes Lighthouse::GraphQL::Type, which is for graphql-ruby types: a subclass of GraphQL::Schema::Object, " \
100
+ 'Union or Scalar, or a module including GraphQL::Schema::Interface (class Types::PostType < Types::BaseObject).'
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Which object type a value of a union or interface is: graphql-ruby asks the
4
+ # schema (its `resolve_type`) each time a field returns one. In order:
5
+ #
6
+ # 1. `@union(resolveType: "Name")` / `@interface(resolveType: "Name")` on the
7
+ # SDL type names a class, looked up in Config.namespaces[:types] (nested
8
+ # names too) and then as written, with `resolve_type(object, context)` or
9
+ # `call(object, context)`. PHP Lighthouse's directives.
10
+ # 2. Its type class, Types::<Name>Type including Lighthouse::GraphQL::Type: a
11
+ # union's `self.resolve_type(object, context)`, or an interface's
12
+ # `definition_methods { def resolve_type(object, context) }`, as in graphql-ruby.
13
+ # 3. The default, as PHP Lighthouse's: a Hash's `__typename`; else the type
14
+ # named after the object's class, or one of its superclasses (STI); else the
15
+ # type whose @model names the object's model (`type Article @model(class: "Post")`).
16
+ #
17
+ # A class returns a type name, the SDL type, or a type class (Types::PostType),
18
+ # which stands for the SDL type of its graphql_name; or, as graphql-ruby allows,
19
+ # [type, value] to resolve the type's fields on `value`. nil falls through to the
20
+ # next step. A value no step resolves, or one resolved to a type the union or
21
+ # interface does not include, raises Lighthouse::DefinitionError.
22
+ module Lighthouse::GraphQL::TypeResolution
23
+ DEFINITIONS = {
24
+ 'union' => <<~GRAPHQL.chomp,
25
+ """
26
+ The class that picks the object type of this union's values: `resolve_type(object, context)`
27
+ or `call(object, context)`, returning a type name or a type class.
28
+ """
29
+ directive @union(resolveType: String!) on UNION
30
+ GRAPHQL
31
+ 'interface' => <<~GRAPHQL.chomp
32
+ """
33
+ The class that picks the object type of this interface's values: `resolve_type(object, context)`
34
+ or `call(object, context)`, returning a type name or a type class.
35
+ """
36
+ directive @interface(resolveType: String!) on INTERFACE
37
+ GRAPHQL
38
+ }.freeze
39
+
40
+ DIRECTIVES = DEFINITIONS.keys.freeze
41
+
42
+ module_function
43
+
44
+ # The object type, or [object type, value] when a class answered with a
45
+ # value to resolve the fields on instead of `object` (as graphql-ruby allows).
46
+ def resolve(schema, abstract_type, object, context)
47
+ answer = custom(abstract_type, object, context)
48
+ answer, value = answer if answer.is_a?(Array) && answer.size == 2
49
+ type = type_named(schema, answer) || default(schema, abstract_type, object)
50
+ unless type && schema.possible_types(abstract_type).include?(type)
51
+ raise Lighthouse::DefinitionError, unresolved_message(schema, abstract_type, object, type)
52
+ end
53
+
54
+ value.nil? ? type : [type, value]
55
+ end
56
+
57
+ # What the directive's class or the type class says, or nil.
58
+ def custom(abstract_type, object, context)
59
+ resolver = directive_class(abstract_type) || type_class(abstract_type)
60
+ return unless resolver
61
+
62
+ resolver.respond_to?(:resolve_type) ? resolver.resolve_type(object, context) : resolver.call(object, context)
63
+ end
64
+
65
+ def default(schema, abstract_type, object)
66
+ candidates = schema.possible_types(abstract_type)
67
+ by_name = candidates.index_by(&:graphql_name)
68
+
69
+ typename = typename_of(object)
70
+ return by_name[typename] if typename
71
+
72
+ class_chain(object.class).each { |name| return by_name[name] if by_name.key?(name) }
73
+ candidates.find { |type| model_of(type)&.then { |model| object.is_a?(model) } }
74
+ end
75
+
76
+ # Fail at build time, not on the first query: every class a @union /
77
+ # @interface names exists and answers, and every type class matches its kind.
78
+ # `bases`: the schema's base_types, by kind, that type classes inherit from.
79
+ def validate!(schema, bases = {})
80
+ schema.types.each_value do |type|
81
+ next unless type.kind.union? || type.kind.interface?
82
+
83
+ directive_class(type)
84
+ kind = type.kind.union? ? :union : :interface
85
+ klass = Lighthouse::GraphQL::Type.class_for(type.graphql_name, kind)
86
+ Lighthouse::GraphQL::ResolverBases.check!(klass, bases[kind], "the #{type.graphql_name} #{kind}") if klass
87
+ end
88
+ end
89
+
90
+ # The SDL type a resolver's answer stands for: a name, a type, or a type class.
91
+ def type_named(schema, answer)
92
+ return if answer.nil?
93
+
94
+ schema.get_type(answer.respond_to?(:graphql_name) ? answer.graphql_name : answer.to_s)
95
+ end
96
+
97
+ def directive_class(abstract_type)
98
+ directive, name = resolve_type_directive(abstract_type)
99
+ return unless name
100
+
101
+ klass = Lighthouse::Support::ModelResolver.constantize(name, namespaces: Lighthouse::Config.namespaces[:types], nested: true)
102
+ return klass if klass.respond_to?(:resolve_type) || klass.respond_to?(:call)
103
+
104
+ raise Lighthouse::DefinitionError,
105
+ "@#{directive.name}(resolveType: #{name.inspect}) on #{abstract_type.graphql_name} names no class with resolve_type(object, context) " \
106
+ 'or call(object, context).'
107
+ end
108
+
109
+ # The @union / @interface on the type and the class name it gives, or nil.
110
+ def resolve_type_directive(abstract_type)
111
+ directive = (abstract_type.ast_node&.directives || []).find { |d| DIRECTIVES.include?(d.name) }
112
+ [directive, Lighthouse::Support::DirectiveArgs.literal(directive, 'resolveType')] if directive
113
+ end
114
+
115
+ def type_class(abstract_type)
116
+ kind = abstract_type.kind.union? ? :union : :interface
117
+ klass = Lighthouse::GraphQL::Type.class_for(abstract_type.graphql_name, kind)
118
+ klass if klass.respond_to?(:resolve_type)
119
+ end
120
+
121
+ def typename_of(object)
122
+ return unless object.is_a?(Hash)
123
+
124
+ (object['__typename'] || object[:__typename])&.to_s
125
+ end
126
+
127
+ # Admin, User, ApplicationRecord, ... for an Admin < User record.
128
+ def class_chain(klass)
129
+ names = []
130
+ while klass
131
+ names << klass.name.demodulize if klass.name
132
+ klass = klass.superclass
133
+ end
134
+ names
135
+ end
136
+
137
+ def model_of(type)
138
+ Lighthouse::GraphQL::Directives::ModelDirective.declared_model(type)
139
+ end
140
+
141
+ def unresolved_message(schema, abstract_type, object, type)
142
+ possible = schema.possible_types(abstract_type).map(&:graphql_name).join(' | ')
143
+ kind = abstract_type.kind.union? ? 'union' : 'interface'
144
+ return "#{abstract_type.graphql_name} resolved a #{object.class} to #{type.graphql_name}, which is not one of its types (#{possible})." if type
145
+
146
+ "Lighthouse could not tell the type of a #{object.class} in the #{kind} #{abstract_type.graphql_name} (#{possible}): " \
147
+ "name a type after its class, map one to it with @model(class: \"#{object.class}\"), or resolve it in " \
148
+ "Types::#{abstract_type.graphql_name}Type.resolve_type or with @#{kind}(resolveType:)."
149
+ end
150
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The `Upload` scalar: a file a client sends with a multipart request (the
4
+ # GraphQL multipart request specification, MultipartRequest). `scalar Upload`
5
+ # in the SDL reads its values through it unless the SDL or a type class names
6
+ # another class. The resolver gets the uploaded file (an
7
+ # ActionDispatch::Http::UploadedFile in Rails): attach it to a model
8
+ # (`avatar: Upload` with @create on a model with has_one_attached :avatar), or
9
+ # store it with @upload.
10
+ class Lighthouse::GraphQL::Upload < GraphQL::Schema::Scalar
11
+ graphql_name 'Upload'
12
+
13
+ def self.coerce_input(value, _context)
14
+ return value if file?(value)
15
+
16
+ raise ::GraphQL::CoercionError, 'Could not get uploaded file, be sure to conform to the GraphQL multipart request specification.'
17
+ end
18
+
19
+ def self.coerce_result(_value, _context)
20
+ raise ::GraphQL::Error, 'The Upload scalar can only be used as an argument, not returned.'
21
+ end
22
+
23
+ def self.file?(value)
24
+ value.respond_to?(:read) && (value.respond_to?(:original_filename) || value.respond_to?(:path))
25
+ end
26
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ # Where @upload puts a file, by default: an ActiveStorage blob, on the service
6
+ # `disk` names (the default service without it), its key under `path`. The
7
+ # argument then holds the blob's signed id, which a has_one_attached /
8
+ # has_many_attached attribute attaches, and which ActiveStorage::Blob.find_signed
9
+ # finds. Config.upload_store takes another `call(file, disk:, path:, public:)`.
10
+ module Lighthouse::GraphQL::UploadStore
11
+ module_function
12
+
13
+ def call(file, disk: nil, path: nil, public: false) # rubocop:disable Lint/UnusedMethodArgument -- public is the service's setting
14
+ unless defined?(::ActiveStorage::Blob)
15
+ raise Lighthouse::DefinitionError,
16
+ '@upload stores files with ActiveStorage: install it (bin/rails active_storage:install), or set Config.upload_store.'
17
+ end
18
+
19
+ attributes = { io: file, filename: filename(file), content_type: file.try(:content_type), key: key(path) }
20
+ attributes[:service_name] = disk if disk
21
+ ::ActiveStorage::Blob.create_and_upload!(**attributes.compact).signed_id
22
+ end
23
+
24
+ def filename(file)
25
+ file.respond_to?(:original_filename) ? file.original_filename : File.basename(file.path)
26
+ end
27
+
28
+ def key(path)
29
+ prefix = path.to_s.delete_prefix('/').delete_suffix('/')
30
+ prefix.empty? ? nil : "#{prefix}/#{SecureRandom.base36(28)}"
31
+ end
32
+ end
@@ -104,8 +104,9 @@ module Lighthouse
104
104
  # Install the field's resolver on `target_type_class` (a graphql-ruby type class).
105
105
  def define_resolver: (untyped target_type_class) -> untyped
106
106
 
107
- def apply_where_filters: (relation relation, untyped args) -> relation
108
- def apply_arg_builders: (relation relation, arguments args_h) -> relation
107
+ # `ctx`: the query's context, which @builder on an argument receives.
108
+ def apply_where_filters: (relation relation, untyped args, ?context? ctx) -> relation
109
+ def apply_arg_builders: (relation relation, arguments args_h, ?context? ctx) -> relation
109
110
  def extract_relation_name_from_directive: (untyped arg_node) -> String?
110
111
  def constantize_model: (String? name) -> Class?
111
112
  def apply_scopes: (relation relation) -> relation