lighthouse-graphql 0.3.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +182 -1
  3. data/README.md +18 -7
  4. data/docs/best-practices.md +16 -6
  5. data/docs/configuration.md +33 -8
  6. data/docs/directives.md +318 -10
  7. data/docs/events.md +3 -1
  8. data/docs/federation.md +3 -2
  9. data/docs/generators.md +82 -0
  10. data/docs/getting-started.md +66 -32
  11. data/docs/motivation.md +3 -2
  12. data/docs/relationships.md +42 -0
  13. data/docs/relay.md +137 -0
  14. data/docs/resolvers.md +338 -25
  15. data/docs/security.md +227 -0
  16. data/docs/subscriptions.md +60 -37
  17. data/docs/types.md +219 -0
  18. data/docs/uploads.md +80 -0
  19. data/lib/generators/lighthouse/base_classes.rb +70 -0
  20. data/lib/generators/lighthouse/class_generator.rb +19 -8
  21. data/lib/generators/lighthouse/install/install_generator.rb +96 -20
  22. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +19 -10
  23. data/lib/generators/lighthouse/install/templates/schema.rb.tt +21 -2
  24. data/lib/generators/lighthouse/mutation_generator.rb +5 -4
  25. data/lib/generators/lighthouse/query_generator.rb +5 -4
  26. data/lib/generators/lighthouse/subscription_generator.rb +21 -16
  27. data/lib/lighthouse/async_mutation_job.rb +17 -0
  28. data/lib/lighthouse/complexity.rb +175 -0
  29. data/lib/lighthouse/complexity_analyzer.rb +56 -0
  30. data/lib/lighthouse/config.rb +16 -4
  31. data/lib/lighthouse/controller.rb +27 -9
  32. data/lib/lighthouse/dataloader_sources/preload_loader.rb +16 -0
  33. data/lib/lighthouse/dataloader_sources/relation_counts_loader.rb +46 -0
  34. data/lib/lighthouse/default_resolve_dispatcher.rb +26 -0
  35. data/lib/lighthouse/directive_resolvers/all_resolver.rb +1 -1
  36. data/lib/lighthouse/directive_resolvers/argument_binding.rb +61 -0
  37. data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +220 -0
  38. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +2 -2
  39. data/lib/lighthouse/directive_resolvers/async_resolver.rb +90 -0
  40. data/lib/lighthouse/directive_resolvers/base.rb +63 -14
  41. data/lib/lighthouse/directive_resolvers/builder_applier.rb +89 -19
  42. data/lib/lighthouse/directive_resolvers/can_resolvers.rb +1 -1
  43. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +16 -5
  44. data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +1 -1
  45. data/lib/lighthouse/directive_resolvers/count_resolver.rb +1 -1
  46. data/lib/lighthouse/directive_resolvers/field_resolver.rb +35 -7
  47. data/lib/lighthouse/directive_resolvers/first_resolver.rb +1 -1
  48. data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +57 -0
  49. data/lib/lighthouse/directive_resolvers/lazy_load_resolver.rb +61 -0
  50. data/lib/lighthouse/directive_resolvers/limit_resolver.rb +59 -0
  51. data/lib/lighthouse/directive_resolvers/method_resolver.rb +38 -3
  52. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +1 -1
  53. data/lib/lighthouse/directive_resolvers/namespaced_resolver.rb +31 -0
  54. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +1 -1
  55. data/lib/lighthouse/directive_resolvers/resolution.rb +19 -4
  56. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +14 -5
  57. data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +77 -0
  58. data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +81 -0
  59. data/lib/lighthouse/directive_resolvers/with_count_resolver.rb +43 -0
  60. data/lib/lighthouse/directive_resolvers/with_resolver.rb +42 -0
  61. data/lib/lighthouse/directives/arguments/more_filters.rb +215 -0
  62. data/lib/lighthouse/directives/definitions.rb +52 -0
  63. data/lib/lighthouse/directives/model_directive.rb +131 -0
  64. data/lib/lighthouse/directives/node_directive.rb +134 -0
  65. data/lib/lighthouse/directives/pagination_manipulator.rb +1 -1
  66. data/lib/lighthouse/directives/registry.rb +21 -20
  67. data/lib/lighthouse/directives/visibility_directive.rb +71 -0
  68. data/lib/lighthouse/enum_directive.rb +41 -0
  69. data/lib/lighthouse/events/execution_trace.rb +2 -1
  70. data/lib/lighthouse/events/lifecycle.rb +3 -1
  71. data/lib/lighthouse/field_resolver_stack.rb +59 -11
  72. data/lib/lighthouse/generated_resolvers.rb +13 -8
  73. data/lib/lighthouse/global_id.rb +31 -0
  74. data/lib/lighthouse/graphql/version.rb +1 -1
  75. data/lib/lighthouse/multipart_request.rb +43 -0
  76. data/lib/lighthouse/node_registry.rb +68 -0
  77. data/lib/lighthouse/pagination.rb +3 -0
  78. data/lib/lighthouse/rb_lighthouse.rb +36 -0
  79. data/lib/lighthouse/relation_counts.rb +29 -0
  80. data/lib/lighthouse/resolve_info.rb +80 -0
  81. data/lib/lighthouse/resolver_bases.rb +68 -0
  82. data/lib/lighthouse/resolver_dispatch.rb +36 -11
  83. data/lib/lighthouse/scalar_coercion.rb +72 -0
  84. data/lib/lighthouse/schema_cache.rb +2 -1
  85. data/lib/lighthouse/schema_factory.rb +0 -1
  86. data/lib/lighthouse/schema_generator.rb +21 -12
  87. data/lib/lighthouse/schema_implementation.rb +4 -8
  88. data/lib/lighthouse/sdl_loader.rb +0 -2
  89. data/lib/lighthouse/support/environment.rb +13 -0
  90. data/lib/lighthouse/support/model_resolver.rb +8 -6
  91. data/lib/lighthouse/support/preload.rb +49 -0
  92. data/lib/lighthouse/type.rb +103 -0
  93. data/lib/lighthouse/type_resolution.rb +150 -0
  94. data/lib/lighthouse/upload.rb +26 -0
  95. data/lib/lighthouse/upload_store.rb +32 -0
  96. data/sig/lighthouse/directives.rbs +3 -2
  97. data/sig/lighthouse/graphql.rbs +29 -0
  98. data/sig/lighthouse.rbs +18 -0
  99. metadata +43 -1
@@ -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)
@@ -45,17 +45,21 @@ module Lighthouse::GraphQL::SchemaGenerator
45
45
  # and fresh (see SchemaCache), else built now.
46
46
  document = build_document(sdl_string)
47
47
  impl = default_implementation
48
+ # base_types' resolver / mutation / subscription are Lighthouse's, not graphql-ruby's.
49
+ bases = Lighthouse::GraphQL::ResolverBases.extract!(opts)
48
50
  schema = from_document(document, impl, opts)
49
51
 
50
52
  delegate_to_federation_sdl(schema)
51
53
  Lighthouse::GraphQL::GeneratedResolvers.install_dispatcher(schema)
52
54
  attach_schema(impl, schema)
53
- post_process(schema, impl, **opts)
55
+ post_process(schema, impl, bases: bases, **opts)
54
56
  schema.trace_with(Lighthouse::Events::ExecutionTrace)
55
57
 
56
58
  # @cacheControl hints become the response's Cache-Control header,
57
59
  # computed per query from the fields it selects.
58
60
  schema.query_analyzer(Lighthouse::GraphQL::CacheControl::Analyzer) if uses_cache_control
61
+ # The score graphql-ruby checks against max_complexity, for BuildExtensionsResponse.
62
+ schema.query_analyzer(Lighthouse::GraphQL::Complexity::Analyzer)
59
63
 
60
64
  schema
61
65
  end
@@ -124,7 +128,8 @@ module Lighthouse::GraphQL::SchemaGenerator
124
128
  augment_sdl_with_connections(inject_directive_definitions(sdl))
125
129
  end
126
130
 
127
- def post_process(schema, _impl, **_opts)
131
+ # `bases`: { root kind => the base class its fields' classes inherit from } (ResolverBases).
132
+ def post_process(schema, _impl, bases: {}, **_opts)
128
133
  root_kinds = root_kinds(schema)
129
134
 
130
135
  # Inspect types and fields for directives and attach generated resolvers
@@ -134,15 +139,23 @@ module Lighthouse::GraphQL::SchemaGenerator
134
139
  # Apply object-level federation directives to generated types so the
135
140
  # Apollo Federation helper can detect entities and add _entities.
136
141
  Lighthouse::GraphQL::FederationDirectives.apply(type)
137
- type.fields.each_value { |field_def| process_field(type, field_def, root_kinds[type.graphql_name]) }
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) }
138
145
  end
139
146
 
147
+ # @complexity, and paginated fields, give graphql-ruby's complexity analysis their cost.
148
+ Lighthouse::GraphQL::Complexity.apply(schema, root_kinds)
149
+
140
150
  # @feature hides a field or type per query, through graphql-ruby's visibility.
141
151
  Lighthouse::GraphQL::FeatureDirective.apply(schema)
142
152
 
143
153
  # Every @broadcast must name a field of the Subscription type.
144
154
  Lighthouse::GraphQL::Broadcasting.validate!(schema)
145
155
 
156
+ # Unions, interfaces and scalars get the classes that resolve and coerce them.
157
+ Lighthouse::GraphQL::Type.apply(schema, bases.fetch(:types, {}))
158
+
146
159
  # return schema for convenience
147
160
  schema
148
161
  end
@@ -155,10 +168,11 @@ module Lighthouse::GraphQL::SchemaGenerator
155
168
 
156
169
  # A field's generated resolvers, then the runtime hooks that make
157
170
  # graphql-ruby reach them.
158
- def process_field(type, field_def, root_kind)
171
+ def process_field(type, field_def, root_kind, bases = {}, type_class = nil)
159
172
  return unless field_def.ast_node
160
173
 
161
- apply_field_resolvers(type, field_def, field_def.ast_node.directives || [], root_kind)
174
+ # Installed in resolution order (see FieldResolverStack).
175
+ Lighthouse::GraphQL::FieldResolverStack.apply(type, field_def, root_kind, bases: bases, type_class: type_class)
162
176
  Lighthouse::GraphQL::GeneratedResolvers.route_runtime_resolver(type, field_def)
163
177
  ensure_connection_extension(field_def)
164
178
  end
@@ -179,11 +193,6 @@ module Lighthouse::GraphQL::SchemaGenerator
179
193
  # ignore failures during post-processing
180
194
  end
181
195
 
182
- # A field's resolvers, installed in resolution order (see FieldResolverStack).
183
- def apply_field_resolvers(type, field_def, directives, root_kind)
184
- Lighthouse::GraphQL::FieldResolverStack.apply(type, field_def, directives, root_kind)
185
- end
186
-
187
196
  # Scan SDL string for list-typed fields that carry relationship directives
188
197
  # and produce Connection/Edge types where missing. This is a lightweight
189
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)
@@ -6,8 +6,6 @@ module Lighthouse::GraphQL::SDLLoader
6
6
  def load_from_folder(folder)
7
7
  files = Dir[File.join(folder, '**', '*.graphql')]
8
8
  if files.empty?
9
- # fallback to the monolithic schema.graphql in apps/chatwoot
10
- # schema.graphql is located at apps/chatwoot/schema.graphql
11
9
  default = File.expand_path('../../..//schema.graphql', __dir__)
12
10
  return File.read(default) if File.exist?(default)
13
11
 
@@ -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
@@ -25,15 +25,17 @@ module Lighthouse::Support::ModelResolver
25
25
 
26
26
  # `namespaces:` overrides the configured resolver_namespaces, e.g. with
27
27
  # Config.namespaces[:builders] to look a builder class up.
28
- # `fallback: false` skips the bare name, so `Queries::User` is never
29
- # mistaken for the `User` model.
30
- def constantize(name, namespaces: self.namespaces, fallback: true)
28
+ # `fallback: false` skips the bare name, so `Resolvers::User` is never
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
@@ -97,6 +97,35 @@ module Lighthouse
97
97
  def self?.apply: (untyped response, untyped result) -> String?
98
98
  end
99
99
 
100
+ # What fields cost in graphql-ruby's complexity analysis (@complexity, and
101
+ # paginated fields scaled by `first`).
102
+ module Complexity
103
+ DEFINITION: String
104
+ CONTEXT_KEY: Symbol
105
+
106
+ # Give the schema's fields their cost; `root_kinds` maps root type names to
107
+ # :queries / :mutations / :subscriptions.
108
+ def self?.apply: (untyped schema, ?Hash[String, Symbol] root_kinds) -> untyped
109
+ # A query's score, once scored for BuildExtensionsResponse; nil otherwise.
110
+ def self?.query_complexity: (untyped query) -> Integer?
111
+ end
112
+
113
+ # A concern for a GraphQL::Analysis::Analyzer that @complexity(resolver:)
114
+ # names (graphql-ruby ships no RBS, so the class it is included in is not
115
+ # declared): its `complexity` is the cost of the fields naming the class.
116
+ module ComplexityAnalyzer
117
+ # Only when the class has a `result` of its own.
118
+ def analyze?: () -> bool
119
+ def result: () -> nil
120
+
121
+ # The class methods the concern adds to its includer.
122
+ module ClassMethods
123
+ # A number (added to the selection's cost) or a proc of
124
+ # (context, args, child_complexity); nil when never set.
125
+ def complexity: (?(Numeric | ^(context, arguments, Integer) -> Integer)? new_complexity) -> (Numeric | ^(context, arguments, Integer) -> Integer)?
126
+ end
127
+ end
128
+
100
129
  # The opt-in cache of the manipulated schema document, in tmp/cache.
101
130
  module SchemaCache
102
131
  PREFIX: String
data/sig/lighthouse.rbs CHANGED
@@ -39,6 +39,18 @@ module Lighthouse
39
39
  def call: (StandardError error, context? context) -> void
40
40
  end
41
41
 
42
+ # Config.global_id: encodes and decodes Relay global ids (@globalId, @node).
43
+ interface _GlobalId
44
+ def encode: (String type, untyped id) -> String
45
+ def decode: (String global_id) -> Array[String]
46
+ end
47
+
48
+ # Config.upload_store: stores the file an @upload argument carries, and returns
49
+ # what the argument holds instead (an ActiveStorage blob's signed id by default).
50
+ interface _UploadStore
51
+ def call: (untyped file, ?disk: String?, ?path: String?, ?public: bool?) -> untyped
52
+ end
53
+
42
54
  # Config.authenticated_user and Config.policy_user: the user of a request.
43
55
  interface _UserReader
44
56
  def call: (context? context) -> untyped
@@ -132,6 +144,12 @@ module Lighthouse
132
144
  def self.convert_empty_strings_to_null: () -> bool
133
145
  def self.convert_empty_strings_to_null=: (bool? enabled) -> bool?
134
146
 
147
+ def self.global_id: () -> _GlobalId
148
+ def self.global_id=: (_GlobalId? codec) -> _GlobalId?
149
+
150
+ def self.upload_store: () -> _UploadStore
151
+ def self.upload_store=: (_UploadStore? store) -> _UploadStore?
152
+
135
153
  # Whether @broadcast triggers in an ActiveJob (Lighthouse::GraphQL::BroadcastJob)
136
154
  # unless the directive says shouldQueue; and the job's queue (nil: ActiveJob's default).
137
155
  def self.queue_broadcasts: () -> bool