lighthouse-graphql 0.1.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 (63) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +134 -0
  5. data/docs/authorization.md +109 -0
  6. data/docs/best-practices.md +75 -0
  7. data/docs/configuration.md +70 -0
  8. data/docs/custom-directives.md +129 -0
  9. data/docs/directives.md +298 -0
  10. data/docs/federation.md +108 -0
  11. data/docs/filtering-and-ordering.md +128 -0
  12. data/docs/getting-started.md +152 -0
  13. data/docs/relationships.md +95 -0
  14. data/lib/lighthouse/config.rb +112 -0
  15. data/lib/lighthouse/contracts/arg_builder.rb +17 -0
  16. data/lib/lighthouse/contracts/arg_manipulator.rb +17 -0
  17. data/lib/lighthouse/contracts/directive.rb +17 -0
  18. data/lib/lighthouse/contracts/field_manipulator.rb +17 -0
  19. data/lib/lighthouse/contracts/field_middleware.rb +16 -0
  20. data/lib/lighthouse/contracts/field_resolver.rb +18 -0
  21. data/lib/lighthouse/dataloader_sources/association_loader.rb +25 -0
  22. data/lib/lighthouse/dataloader_sources/model_loader.rb +19 -0
  23. data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +86 -0
  24. data/lib/lighthouse/directive_resolvers/all_resolver.rb +73 -0
  25. data/lib/lighthouse/directive_resolvers/auth_resolver.rb +44 -0
  26. data/lib/lighthouse/directive_resolvers/base.rb +92 -0
  27. data/lib/lighthouse/directive_resolvers/belongs_to_many_resolver.rb +56 -0
  28. data/lib/lighthouse/directive_resolvers/belongs_to_resolver.rb +47 -0
  29. data/lib/lighthouse/directive_resolvers/builder_applier.rb +63 -0
  30. data/lib/lighthouse/directive_resolvers/can_resolver.rb +86 -0
  31. data/lib/lighthouse/directive_resolvers/count_resolver.rb +56 -0
  32. data/lib/lighthouse/directive_resolvers/field_resolver.rb +78 -0
  33. data/lib/lighthouse/directive_resolvers/find_resolver.rb +65 -0
  34. data/lib/lighthouse/directive_resolvers/first_resolver.rb +53 -0
  35. data/lib/lighthouse/directive_resolvers/guard_resolver.rb +32 -0
  36. data/lib/lighthouse/directive_resolvers/has_many_resolver.rb +109 -0
  37. data/lib/lighthouse/directive_resolvers/has_one_resolver.rb +43 -0
  38. data/lib/lighthouse/directive_resolvers/method_resolver.rb +35 -0
  39. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +147 -0
  40. data/lib/lighthouse/directive_resolvers/rename_resolver.rb +35 -0
  41. data/lib/lighthouse/directive_resolvers/resolution.rb +35 -0
  42. data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +169 -0
  43. data/lib/lighthouse/directives/arguments/base.rb +40 -0
  44. data/lib/lighthouse/directives/arguments/filters.rb +101 -0
  45. data/lib/lighthouse/directives/arguments/order_by.rb +48 -0
  46. data/lib/lighthouse/directives/registry.rb +168 -0
  47. data/lib/lighthouse/directives/relation_directive.rb +332 -0
  48. data/lib/lighthouse/directives/where_conditions_directive.rb +208 -0
  49. data/lib/lighthouse/graphql/version.rb +7 -0
  50. data/lib/lighthouse/graphql.rb +8 -0
  51. data/lib/lighthouse/rb_lighthouse.rb +88 -0
  52. data/lib/lighthouse/reference_resolver.rb +24 -0
  53. data/lib/lighthouse/schema_factory.rb +46 -0
  54. data/lib/lighthouse/schema_generator.rb +244 -0
  55. data/lib/lighthouse/schema_implementation.rb +119 -0
  56. data/lib/lighthouse/sdl_loader.rb +27 -0
  57. data/lib/lighthouse/support/authorization.rb +26 -0
  58. data/lib/lighthouse/support/directive_args.rb +33 -0
  59. data/lib/lighthouse/support/model_resolver.rb +61 -0
  60. data/lib/lighthouse/support/naming.rb +71 -0
  61. data/lib/lighthouse/where_conditions/operator_map.rb +35 -0
  62. data/lib/lighthouse-graphql.rb +10 -0
  63. metadata +195 -0
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'graphql'
4
+
5
+ module Lighthouse
6
+ module GraphQL
7
+ module Directives
8
+ class WhereConditionsDirective
9
+ SQLOPERATOR_TYPE_NAME = 'SQLOperator'.freeze
10
+ MIXED_SCALAR_NAME = 'Mixed'.freeze
11
+ PLACEHOLDER_SCALAR_NAME = '_'.freeze
12
+
13
+ # Supported operators in the generated GraphQL input type performance.
14
+ SUPPORTED_OPERATORS = %w[EQ NEQ GT GTE LT LTE IN NIN CONTAINS STARTS_WITH ENDS_WITH IS_NULL IS_NOT_NULL].freeze
15
+
16
+ def self.manipulate(sdl)
17
+ return sdl unless sdl.is_a?(String) && !sdl.empty?
18
+ begin
19
+ ast = ::GraphQL::Language::Parser.parse(sdl)
20
+ rescue => e
21
+ warn("Lighthouse::GraphQL::Directives::WhereConditionsDirective: parse failed: #{e.class}: #{e.message}")
22
+ return sdl
23
+ end
24
+ existing_defs = ast.definitions.map { |defn| defn.respond_to?(:name) ? defn.name : nil }.compact
25
+
26
+ generated = []
27
+
28
+ object_types = ast.definitions.select do |defn|
29
+ defn.is_a?(::GraphQL::Language::Nodes::ObjectTypeDefinition) || defn.is_a?(::GraphQL::Language::Nodes::InterfaceTypeDefinition)
30
+ end
31
+
32
+ object_types.each do |type_def|
33
+ (type_def.fields || []).each do |field|
34
+ (field.arguments || []).each do |arg|
35
+ directive = arg.directives&.find { |d| %w[whereConditions whereHasConditions].include?(d.name) }
36
+ next unless directive
37
+
38
+ if arg.description.nil? || arg.description.empty?
39
+ arg.instance_variable_set(
40
+ :@description,
41
+ directive.name == 'whereHasConditions' ? 'Filter results by related records conditions.' : 'Filter results by field conditions.'
42
+ )
43
+ end
44
+
45
+ input_type_name = "#{camelize(type_def.name)}#{camelize(field.name)}#{camelize(arg.name)}WhereConditions"
46
+ column_enum_name = "#{camelize(type_def.name)}#{camelize(field.name)}#{camelize(arg.name)}Column"
47
+ relation_input_name = "#{input_type_name}Relation"
48
+
49
+ apply_arg_type_replacement(arg, input_type_name)
50
+
51
+ # Build input type if needed.
52
+ unless existing_defs.include?(input_type_name)
53
+ generated << build_where_condition_input(input_type_name, column_enum_name, relation_input_name, directive)
54
+ existing_defs << input_type_name
55
+ end
56
+
57
+ # Build relation type for HAS support in whereConditions
58
+ if directive.name == 'whereConditions' && !existing_defs.include?(relation_input_name)
59
+ generated << build_where_condition_relation_input(relation_input_name, input_type_name)
60
+ existing_defs << relation_input_name
61
+ end
62
+
63
+ # Build column enum type unless columnsEnum is provided.
64
+ columns_enum = directive_argument_value(directive, 'columnsEnum')
65
+ columns = directive_argument_value(directive, 'columns')
66
+ if columns_enum.nil? && columns && !columns.empty? && !existing_defs.include?(column_enum_name)
67
+ generated << build_column_enum(column_enum_name, columns)
68
+ existing_defs << column_enum_name
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ # Append base utility types only once
75
+ unless existing_defs.include?(SQLOPERATOR_TYPE_NAME)
76
+ generated << build_sql_operator_enum
77
+ existing_defs << SQLOPERATOR_TYPE_NAME
78
+ end
79
+
80
+ unless existing_defs.include?(MIXED_SCALAR_NAME)
81
+ generated << "scalar #{MIXED_SCALAR_NAME}"
82
+ existing_defs << MIXED_SCALAR_NAME
83
+ end
84
+
85
+ # Some SDL files use `_` as a placeholder input type, even when no
86
+ # whereConditions directive is attached (e.g. WIP schema pieces). If we
87
+ # don't define it, GraphQL-Ruby raises UnresolvedLateBoundTypeError.
88
+ if contains_placeholder_type?(ast) && !existing_defs.include?(PLACEHOLDER_SCALAR_NAME)
89
+ generated << "scalar #{PLACEHOLDER_SCALAR_NAME}"
90
+ existing_defs << PLACEHOLDER_SCALAR_NAME
91
+ end
92
+
93
+ return sdl if generated.empty?
94
+
95
+ modified_sdl = ::GraphQL::Language::Printer.new.print(ast)
96
+ modified_sdl + "\n\n" + generated.join("\n\n")
97
+ end
98
+
99
+ def self.apply_arg_type_replacement(arg, input_type_name)
100
+ case arg.type
101
+ when ::GraphQL::Language::Nodes::TypeName
102
+ if arg.type.name == '_'
103
+ new_type = ::GraphQL::Language::Nodes::TypeName.new(name: input_type_name)
104
+ arg.instance_variable_set(:@type, new_type)
105
+ end
106
+ when ::GraphQL::Language::Nodes::NonNullType
107
+ inner = arg.type.of_type
108
+ if inner.is_a?(::GraphQL::Language::Nodes::TypeName) && inner.name == '_'
109
+ arg.instance_variable_set(:@type, ::GraphQL::Language::Nodes::NonNullType.new(of_type: ::GraphQL::Language::Nodes::TypeName.new(name: input_type_name)))
110
+ end
111
+ end
112
+ end
113
+
114
+ def self.build_where_condition_input(type_name, column_enum_name, relation_type_name, directive)
115
+ column_type = directive_argument_value(directive, 'columnsEnum') || column_enum_name
116
+
117
+ fields = []
118
+ fields << " column: #{column_type}"
119
+ fields << " operator: #{SQLOPERATOR_TYPE_NAME} = EQ"
120
+ fields << " value: #{MIXED_SCALAR_NAME}"
121
+ fields << " AND: [#{type_name}!]"
122
+ fields << " OR: [#{type_name}!]"
123
+ fields << " HAS: #{relation_type_name}" if directive.name == 'whereConditions'
124
+
125
+ <<~TEXT.strip
126
+ input #{type_name} {
127
+ #{fields.join("\n")}
128
+ }
129
+ TEXT
130
+ end
131
+
132
+ def self.build_where_condition_relation_input(type_name, condition_type_name)
133
+ <<~TEXT.strip
134
+ input #{type_name} {
135
+ relation: String!
136
+ operator: #{SQLOPERATOR_TYPE_NAME} = GTE
137
+ amount: Int = 1
138
+ condition: #{condition_type_name}
139
+ }
140
+ TEXT
141
+ end
142
+
143
+ def self.build_column_enum(name, columns)
144
+ values = Array(columns).map do |column|
145
+ value = column.to_s
146
+ key = value.gsub(/[^0-9A-Za-z]/, '_').upcase
147
+ " #{key}"
148
+ end.compact.uniq
149
+
150
+ <<~TEXT.strip
151
+ enum #{name} {
152
+ #{values.join("\n")}
153
+ }
154
+ TEXT
155
+ end
156
+
157
+ def self.build_sql_operator_enum
158
+ values = SUPPORTED_OPERATORS.map { |op| " #{op}" }
159
+ <<~TEXT.strip
160
+ enum #{SQLOPERATOR_TYPE_NAME} {
161
+ #{values.join("\n")}
162
+ }
163
+ TEXT
164
+ end
165
+
166
+ def self.directive_argument_value(directive, name)
167
+ arg = directive.arguments&.find { |a| a.name == name }
168
+ return nil unless arg
169
+
170
+ # Supports both Babel-style AST and raw Ruby value.
171
+ value = arg.value.respond_to?(:value) ? arg.value.value : arg.value
172
+
173
+ if name == 'columns' && value.is_a?(Array)
174
+ value.map { |it| it.respond_to?(:value) ? it.value : it }
175
+ else
176
+ value
177
+ end
178
+ end
179
+
180
+ def self.contains_placeholder_type?(document)
181
+ document.definitions.any? do |defn|
182
+ next false unless defn.respond_to?(:fields) && defn.fields.respond_to?(:any?)
183
+ (defn.fields || []).any? do |field|
184
+ (field.respond_to?(:arguments) ? field.arguments || [] : []).any? { |arg| type_references_placeholder?(arg.type) }
185
+ end
186
+ end
187
+ rescue StandardError
188
+ false
189
+ end
190
+
191
+ def self.type_references_placeholder?(type_node)
192
+ case type_node
193
+ when ::GraphQL::Language::Nodes::TypeName
194
+ type_node.name == PLACEHOLDER_SCALAR_NAME
195
+ when ::GraphQL::Language::Nodes::NonNullType, ::GraphQL::Language::Nodes::ListType
196
+ type_references_placeholder?(type_node.of_type)
197
+ else
198
+ false
199
+ end
200
+ end
201
+
202
+ def self.camelize(value)
203
+ value.to_s.split(/[_\s]+/).map { |part| part.slice(0,1).upcase + part.slice(1..).to_s }.join
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lighthouse
4
+ module GraphQL
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Alternate entrypoint matching the namespace:
4
+ #
5
+ # require 'lighthouse/graphql'
6
+ #
7
+ require_relative 'graphql/version'
8
+ require_relative 'rb_lighthouse'
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ActiveSupport core extensions the library relies on (underscore, pluralize,
4
+ # blank?/present?, deep_symbolize_keys). In a Rails host these are already
5
+ # loaded; requiring them here keeps the gem usable standalone.
6
+ require 'active_support'
7
+ require 'active_support/core_ext/string/inflections'
8
+ require 'active_support/core_ext/object/blank'
9
+ require 'active_support/core_ext/hash/keys'
10
+
11
+ ## Load internal dependencies individually so a single missing file
12
+ ## doesn't prevent the rest from loading. Failures are logged but not
13
+ ## raised so callers can require specific files if needed.
14
+ %w[
15
+ config
16
+ reference_resolver
17
+ support/naming
18
+ support/directive_args
19
+ support/model_resolver
20
+ support/authorization
21
+ where_conditions/operator_map
22
+ contracts/directive
23
+ contracts/field_resolver
24
+ contracts/field_middleware
25
+ contracts/field_manipulator
26
+ contracts/arg_manipulator
27
+ contracts/arg_builder
28
+ sdl_loader
29
+ schema_generator
30
+ schema_factory
31
+ schema_implementation
32
+ directives/relation_directive
33
+ directives/where_conditions_directive
34
+ directive_resolvers/has_many_resolver
35
+ directive_resolvers/aggregate_resolver
36
+ directive_resolvers/field_resolver
37
+ directive_resolvers/where_conditions_applier
38
+ directive_resolvers/belongs_to_resolver
39
+ directive_resolvers/all_resolver
40
+ directive_resolvers/find_resolver
41
+ directive_resolvers/paginate_resolver
42
+ directive_resolvers/auth_resolver
43
+ directive_resolvers/belongs_to_many_resolver
44
+ directive_resolvers/has_one_resolver
45
+ directive_resolvers/first_resolver
46
+ directive_resolvers/count_resolver
47
+ directive_resolvers/rename_resolver
48
+ directive_resolvers/method_resolver
49
+ directive_resolvers/base
50
+ directive_resolvers/resolution
51
+ directive_resolvers/guard_resolver
52
+ directive_resolvers/can_resolver
53
+ directives/arguments/base
54
+ directives/arguments/filters
55
+ directives/arguments/order_by
56
+ directives/registry
57
+ dataloader_sources/association_loader
58
+ dataloader_sources/model_loader
59
+ ].each do |rel|
60
+ begin
61
+ require_relative rel
62
+ rescue LoadError => e
63
+ warn("Lighthouse: could not require #{rel}: #{e.message}")
64
+ end
65
+ end
66
+
67
+ module Lighthouse
68
+ module GraphQL
69
+ module RbLightHouse
70
+ class << self
71
+ def get_schema(sdl_folder: nil, options: {})
72
+ base_types = options.delete(:base_types) || {}
73
+ schema_class = options.delete(:base_schema)
74
+
75
+ Lighthouse::GraphQL::SchemaFactory.build_base_schema(
76
+ sdl_folder: sdl_folder,
77
+ base_types: base_types,
78
+ schema_options: options.merge(schema_class: schema_class)
79
+ )
80
+ end
81
+
82
+ def configure(&block)
83
+ Lighthouse::GraphQL::Config.configure(&block) if defined?(Lighthouse::GraphQL::Config)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lighthouse
4
+ class ReferenceResolver
5
+ @resolvers = {}
6
+
7
+ class << self
8
+ def register(typename, &block)
9
+ @resolvers[typename.to_s] = block
10
+ end
11
+
12
+ def resolve(typename, reference, context)
13
+ resolver = @resolvers[typename.to_s]
14
+ return nil unless resolver
15
+
16
+ resolver.call(reference, context)
17
+ end
18
+
19
+ def clear!
20
+ @resolvers.clear
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lighthouse
4
+ module GraphQL
5
+ # Public entrypoint for building an executable schema from SDL.
6
+ #
7
+ # The returned value is a GraphQL-Ruby schema class (from `GraphQL::Schema.from_definition`)
8
+ # intended to be subclassed by the application schema class.
9
+ module SchemaFactory
10
+ module_function
11
+
12
+ def build_base_schema(sdl_folder: nil, sdl: nil, base_types: {}, schema_options: {}, **extra_options)
13
+ # Absorb any extra keyword options (e.g. `federation: true`) into the
14
+ # schema options bag so callers can pass schema-level flags directly
15
+ # without the factory needing to enumerate every one.
16
+ schema_options = extra_options.merge(schema_options)
17
+
18
+ sdl_string =
19
+ if sdl
20
+ sdl
21
+ else
22
+ folder = sdl_folder || (Lighthouse::GraphQL::Config.sdl_folder if defined?(Lighthouse::GraphQL::Config))
23
+ if folder && Dir.exist?(folder)
24
+ Lighthouse::GraphQL::SDLLoader.load_from_folder(folder)
25
+ else
26
+ # fallback to apps/chatwoot/schema.graphql
27
+ path = File.expand_path('../../schema.graphql', __dir__)
28
+ File.read(path)
29
+ end
30
+ end
31
+
32
+ schema_class = schema_options.delete(:schema_class)
33
+
34
+ schema = Lighthouse::GraphQL::SchemaGenerator.build(
35
+ sdl_string,
36
+ base_types: base_types,
37
+ schema_class: schema_class,
38
+ **schema_options
39
+ )
40
+
41
+ schema
42
+ end
43
+ end
44
+ end
45
+ end
46
+
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lighthouse
4
+ module GraphQL
5
+ module SchemaGenerator
6
+ module_function
7
+
8
+ def build(sdl_string, **opts)
9
+ # Contribute SDL definitions for any registered directive the schema uses
10
+ # but hasn't declared, so the app doesn't hand-declare every directive.
11
+ sdl_string = inject_directive_definitions(sdl_string)
12
+ # Augment SDL: convert list-typed fields with relationship directives
13
+ # (e.g. `roles: [Role!]! @belongsToMany`) into connection types
14
+ # (`roles: RoleConnection!`) and generate missing Connection/Edge types.
15
+ sdl_string = augment_sdl_with_connections(sdl_string)
16
+ impl =
17
+ if defined?(::SchemaImplementation)
18
+ ::SchemaImplementation.new
19
+ else
20
+ # Minimal fallback when SchemaImplementation isn't available.
21
+ # Keep the same dispatch semantics so directive resolvers work.
22
+ Class.new do
23
+ attr_accessor :schema
24
+
25
+ def call(type, field, obj, args, ctx)
26
+ field_name = field.respond_to?(:graphql_name) ? field.graphql_name.to_s : field.name.to_s
27
+ owner = schema && schema.types[type.respond_to?(:graphql_name) ? type.graphql_name : type.name.to_s]
28
+
29
+ if owner && owner.respond_to?("resolve_field_#{field_name}")
30
+ resolver = owner.method("resolve_field_#{field_name}")
31
+ param_count = resolver.parameters.length
32
+ return resolver.call(obj, args) if param_count == 2
33
+
34
+ return resolver.call(obj, args, ctx)
35
+ end
36
+
37
+ return field.resolve_proc.call(obj, args, ctx) if field.respond_to?(:resolve_proc) && field.resolve_proc
38
+
39
+ obj.respond_to?(field_name) ? obj.public_send(field_name) : nil
40
+ end
41
+
42
+ def resolve_type(_abstract_type, _obj, _ctx)
43
+ nil
44
+ end
45
+
46
+ def coerce_input(_type, value, _ctx)
47
+ value
48
+ end
49
+
50
+ def coerce_result(_type, value, _ctx)
51
+ value
52
+ end
53
+ end.new
54
+ end
55
+
56
+ final_sdl = sdl_string
57
+ base_types = opts.delete(:base_types) || {}
58
+ using = opts.delete(:using) || {}
59
+ schema_class = opts.delete(:schema_class) || ::GraphQL::Schema
60
+
61
+ # Build using the provided base class so federation / custom schema behavior
62
+ # is preserved through class hierarchy.
63
+ schema = schema_class.from_definition(final_sdl, default_resolve: impl, base_types: base_types, using: using)
64
+
65
+ # Make to_definition delegate to federation_sdl when available.
66
+ if schema.respond_to?(:federation_sdl)
67
+ schema.define_singleton_method(:to_definition) do
68
+ federation_sdl
69
+ end
70
+ end
71
+ # GraphQL-Ruby caches field resolution procs during schema build; rather
72
+ # than trying to override per-field `resolve_<field>` methods post-hoc,
73
+ # we install a single dispatcher here that prefers our generated
74
+ # `resolve_field_<name>` singletons.
75
+ if schema.respond_to?(:definition_default_resolve=) && schema.respond_to?(:definition_default_resolve)
76
+ previous_default = schema.definition_default_resolve
77
+ schema.definition_default_resolve = proc do |type, field, obj, arguments, ctx|
78
+ owner =
79
+ if type.respond_to?(:graphql_name)
80
+ schema.types[type.graphql_name]
81
+ elsif type.respond_to?(:name)
82
+ schema.types[type.name.to_s]
83
+ end
84
+
85
+ field_name = field.respond_to?(:graphql_name) ? field.graphql_name.to_s : field.name.to_s
86
+
87
+ if owner && owner.respond_to?("resolve_field_#{field_name}")
88
+ resolver = owner.method("resolve_field_#{field_name}")
89
+ source_file, = resolver.source_location
90
+ # GraphQL-Ruby's SDL builder also defines `resolve_field_*` methods
91
+ # internally. Only invoke ours (defined under `lib/lighthouse/`),
92
+ # otherwise we may call an internal resolver with the wrong arity.
93
+ if source_file && source_file.include?('/lib/lighthouse/')
94
+ param_count = resolver.parameters.length
95
+ next resolver.call(obj, arguments) if param_count == 2
96
+
97
+ next resolver.call(obj, arguments, ctx)
98
+ end
99
+ end
100
+
101
+ previous_default.call(type, field, obj, arguments, ctx)
102
+ end
103
+ end
104
+
105
+ # Give the implementation object access to the built schema so its
106
+ # runtime dispatcher can look up generated singleton resolvers.
107
+ begin
108
+ impl.schema = schema if impl.respond_to?(:schema=)
109
+ rescue StandardError
110
+ # ignore
111
+ end
112
+
113
+ post_process(schema, impl, **opts)
114
+
115
+ schema
116
+ end
117
+
118
+ def post_process(schema, impl, **_opts)
119
+ # Inspect types and fields for directives and attach generated resolvers
120
+ schema.types.each_value do |t|
121
+ next unless t.kind.object?
122
+
123
+ # Apply object-level federation directives to generated types so the
124
+ # Apollo Federation helper can detect entities and add _entities.
125
+ if t.ast_node && t.respond_to?(:key)
126
+ t.ast_node.directives.each do |dnode|
127
+ case dnode.name
128
+ when 'key'
129
+ arg = dnode.arguments.find { |a| a.name == 'fields' }
130
+ t.key(fields: arg.value) if arg && arg.respond_to?(:value)
131
+
132
+ # Setup federation resolve_reference: prefer a custom resolver
133
+ # registered with ReferenceResolver, but fall back to the base
134
+ # type's own resolve_reference (e.g. BaseObject's default model
135
+ # lookup) so direct-model entities resolve without a custom
136
+ # handler. Capture the inherited implementation before shadowing.
137
+ default_resolve_reference =
138
+ t.respond_to?(:resolve_reference) ? t.method(:resolve_reference) : nil
139
+
140
+ t.define_singleton_method(:resolve_reference) do |reference, context|
141
+ resolved = ::Lighthouse::ReferenceResolver.resolve(t.graphql_name, reference, context)
142
+ next resolved unless resolved.nil?
143
+
144
+ default_resolve_reference ? default_resolve_reference.call(reference, context) : nil
145
+ end
146
+ when 'extends'
147
+ t.extend_type if t.respond_to?(:extend_type)
148
+ when 'shareable'
149
+ t.shareable if t.respond_to?(:shareable)
150
+ when 'inaccessible'
151
+ t.inaccessible if t.respond_to?(:inaccessible)
152
+ when 'interfaceObject'
153
+ t.interface_object if t.respond_to?(:interface_object)
154
+ end
155
+ end
156
+ end
157
+
158
+ t.fields.each do |fname, field_def|
159
+ next unless field_def.ast_node
160
+
161
+ directives = field_def.ast_node.directives || []
162
+ registry = Lighthouse::DirectiveRegistry.default
163
+ # Apply wrapping directives in resolution order: data resolvers first
164
+ # (innermost), then @can (wraps to authorize the result), then guards
165
+ # (outermost, so authentication runs before anything else).
166
+ sorted = directives.sort_by { |d| registry.resolution_order(d.name) }
167
+
168
+ # Dispatch each directive to its registered resolver class. The
169
+ # registry replaces the previous hardcoded `case dnode.name` so that
170
+ # adding a directive is "register a class", not "edit the generator".
171
+ sorted.each do |dnode|
172
+ resolver_class = registry.resolution_class(dnode.name)
173
+ next unless resolver_class
174
+
175
+ resolver_class.new(field_def, t, dnode).define_resolver(t)
176
+ end
177
+
178
+ # GraphQL-Ruby's SDL builder defines `resolve_<field>` methods that call
179
+ # the schema's `definition_default_resolve`. Override `resolve_<field>`
180
+ # to delegate to our generated `resolve_field_<field>` when present, so
181
+ # runtime resolution consistently reaches our directive resolvers.
182
+ begin
183
+ method_name = "resolve_field_#{field_def.name}"
184
+ runtime_name = "resolve_#{field_def.name}"
185
+ if t.respond_to?(method_name)
186
+ t.define_singleton_method(runtime_name) do |obj, arguments, ctx|
187
+ public_send(method_name, obj, arguments, ctx)
188
+ end
189
+ end
190
+ rescue StandardError
191
+ # ignore override failures
192
+ end
193
+
194
+ # Ensure GraphQL-Ruby's ConnectionExtension is applied for SDL-built
195
+ # connection fields so the runtime will wrap returned values into
196
+ # Connection objects (populate_connection will be invoked).
197
+ begin
198
+ # Avoid adding the ConnectionExtension if pagination args were
199
+ # already added by SDL augmentation (to prevent duplicate arg
200
+ # collisions).
201
+ if field_def.connection? && !(field_def.extensions.any? do |e|
202
+ e.is_a?(::GraphQL::Schema::Field::ConnectionExtension)
203
+ end) && !((field_def.arguments || {}).key?(:after) || (field_def.arguments || {}).key?('after'))
204
+ field_def.extension(::GraphQL::Schema::Field::ConnectionExtension,
205
+ call_after_define: false)
206
+ end
207
+ rescue StandardError
208
+ # ignore failures during post-processing
209
+ end
210
+ end
211
+ end
212
+
213
+ # return schema for convenience
214
+ schema
215
+ end
216
+
217
+ # Scan SDL string for list-typed fields that carry relationship directives
218
+ # and produce Connection/Edge types where missing. This is a lightweight
219
+ # transformation that mirrors Lighthouse behavior: clients can declare
220
+ # `roles: [Role!]! @belongsToMany` and the builder will expose a
221
+ # `RoleConnection` type and swap the field to return that connection.
222
+ def augment_sdl_with_connections(sdl)
223
+ # Drive SDL manipulation through the registry so each directive owns its
224
+ # manipulation behavior and new manipulators can be added by registering
225
+ # a class (no edits here).
226
+ Lighthouse::DirectiveRegistry.default.manipulate_sdl(sdl)
227
+ end
228
+
229
+ # Prepend `directive @name(...) on ...` definitions for registered,
230
+ # self-describing directives that the SDL uses but has not declared.
231
+ # Already-declared directives are left untouched (no duplicates).
232
+ def inject_directive_definitions(sdl)
233
+ return sdl unless sdl.is_a?(String)
234
+
235
+ declared = sdl.scan(/directive\s+@(\w+)/).flatten.to_set
236
+ missing = Lighthouse::DirectiveRegistry.default.directive_definitions
237
+ .reject { |name, _| declared.include?(name) }
238
+ return sdl if missing.empty?
239
+
240
+ "#{missing.values.join("\n")}\n\n#{sdl}"
241
+ end
242
+ end
243
+ end
244
+ end