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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +47 -0
- data/LICENSE.txt +21 -0
- data/README.md +134 -0
- data/docs/authorization.md +109 -0
- data/docs/best-practices.md +75 -0
- data/docs/configuration.md +70 -0
- data/docs/custom-directives.md +129 -0
- data/docs/directives.md +298 -0
- data/docs/federation.md +108 -0
- data/docs/filtering-and-ordering.md +128 -0
- data/docs/getting-started.md +152 -0
- data/docs/relationships.md +95 -0
- data/lib/lighthouse/config.rb +112 -0
- data/lib/lighthouse/contracts/arg_builder.rb +17 -0
- data/lib/lighthouse/contracts/arg_manipulator.rb +17 -0
- data/lib/lighthouse/contracts/directive.rb +17 -0
- data/lib/lighthouse/contracts/field_manipulator.rb +17 -0
- data/lib/lighthouse/contracts/field_middleware.rb +16 -0
- data/lib/lighthouse/contracts/field_resolver.rb +18 -0
- data/lib/lighthouse/dataloader_sources/association_loader.rb +25 -0
- data/lib/lighthouse/dataloader_sources/model_loader.rb +19 -0
- data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +86 -0
- data/lib/lighthouse/directive_resolvers/all_resolver.rb +73 -0
- data/lib/lighthouse/directive_resolvers/auth_resolver.rb +44 -0
- data/lib/lighthouse/directive_resolvers/base.rb +92 -0
- data/lib/lighthouse/directive_resolvers/belongs_to_many_resolver.rb +56 -0
- data/lib/lighthouse/directive_resolvers/belongs_to_resolver.rb +47 -0
- data/lib/lighthouse/directive_resolvers/builder_applier.rb +63 -0
- data/lib/lighthouse/directive_resolvers/can_resolver.rb +86 -0
- data/lib/lighthouse/directive_resolvers/count_resolver.rb +56 -0
- data/lib/lighthouse/directive_resolvers/field_resolver.rb +78 -0
- data/lib/lighthouse/directive_resolvers/find_resolver.rb +65 -0
- data/lib/lighthouse/directive_resolvers/first_resolver.rb +53 -0
- data/lib/lighthouse/directive_resolvers/guard_resolver.rb +32 -0
- data/lib/lighthouse/directive_resolvers/has_many_resolver.rb +109 -0
- data/lib/lighthouse/directive_resolvers/has_one_resolver.rb +43 -0
- data/lib/lighthouse/directive_resolvers/method_resolver.rb +35 -0
- data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +147 -0
- data/lib/lighthouse/directive_resolvers/rename_resolver.rb +35 -0
- data/lib/lighthouse/directive_resolvers/resolution.rb +35 -0
- data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +169 -0
- data/lib/lighthouse/directives/arguments/base.rb +40 -0
- data/lib/lighthouse/directives/arguments/filters.rb +101 -0
- data/lib/lighthouse/directives/arguments/order_by.rb +48 -0
- data/lib/lighthouse/directives/registry.rb +168 -0
- data/lib/lighthouse/directives/relation_directive.rb +332 -0
- data/lib/lighthouse/directives/where_conditions_directive.rb +208 -0
- data/lib/lighthouse/graphql/version.rb +7 -0
- data/lib/lighthouse/graphql.rb +8 -0
- data/lib/lighthouse/rb_lighthouse.rb +88 -0
- data/lib/lighthouse/reference_resolver.rb +24 -0
- data/lib/lighthouse/schema_factory.rb +46 -0
- data/lib/lighthouse/schema_generator.rb +244 -0
- data/lib/lighthouse/schema_implementation.rb +119 -0
- data/lib/lighthouse/sdl_loader.rb +27 -0
- data/lib/lighthouse/support/authorization.rb +26 -0
- data/lib/lighthouse/support/directive_args.rb +33 -0
- data/lib/lighthouse/support/model_resolver.rb +61 -0
- data/lib/lighthouse/support/naming.rb +71 -0
- data/lib/lighthouse/where_conditions/operator_map.rb +35 -0
- data/lib/lighthouse-graphql.rb +10 -0
- 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,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
|