Skip to content

Commit 0a40196

Browse files
authored
Organize error handling
2 parents e87aa5e + 0cc861b commit 0a40196

File tree

11 files changed

+164
-76
lines changed

11 files changed

+164
-76
lines changed

README.md

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
![Cardinal](./images/cardinal.png)
22

3-
**An (experimental) breadth-first GraphQL executor for Ruby**
3+
**An (experimental) breadth-first GraphQL executor written in Ruby**
44

55
Depth-first execution resolves every object field descending down a response tree, while breadth-first visits every _selection position_ once with an aggregated set of objects. The breadth-first approach is much faster due to fewer resolver calls and intermediary promises.
66

@@ -45,3 +45,70 @@ While bigger responses will always take longer to process, the workload is your
4545

4646
* Eliminates boilerplate need for DataLoader promises, because resolvers are inherently batched.
4747
* Executes via flat queuing without deep recursion and large call stacks.
48+
49+
## API
50+
51+
Setup a `GraphQL::Cardinal::FieldResolver`:
52+
53+
```ruby
54+
class MyFieldResolver < GraphQL::Cardinal::FieldResolver
55+
def resolve(objects, args, ctx, scope)
56+
map_sources(objects) { |obj| obj.my_field }
57+
end
58+
end
59+
```
60+
61+
A field resolver provides:
62+
63+
* `objects`: the array of objects to resolve the field on.
64+
* `args`: the coerced arguments provided to this selection field.
65+
* `ctx`: the request context.
66+
* `scope`: (experimental) a handle to the execution scope that invokes lazy hooks.
67+
68+
A resolver must return a mapped set of data for the provided objects. Always use the `map_sources` helper for your mapping loop to assure that exceptions are captured properly. You may return errors for a field position by mapping an `ExecutionError` into it:
69+
70+
```ruby
71+
class MyFieldResolver < GraphQL::Cardinal::FieldResolver
72+
def resolve(objects, args, ctx, scope)
73+
map_sources(objects) do |obj|
74+
obj.valid? ? obj.my_field : GraphQL::Cardinal::ExecutionError.new("Object field not valid")
75+
end
76+
end
77+
end
78+
```
79+
80+
Now setup a resolver map:
81+
82+
```ruby
83+
RESOLVER_MAP = {
84+
"MyType" => {
85+
"myField" => MyFieldResolver.new,
86+
},
87+
"Query" => {
88+
"myType" => MyTypeResolver.new,
89+
},
90+
}.freeze
91+
```
92+
93+
Now parse your schema definition and execute requests:
94+
95+
```ruby
96+
SCHEMA = GraphQL::Schema.from_definition(%|
97+
type MyType {
98+
myField: String
99+
}
100+
type Query {
101+
myType: MyType
102+
}
103+
|)
104+
105+
result = GraphQL::Cardinal::Executor.new(
106+
SCHEMA,
107+
RESOLVER_MAP,
108+
GraphQL.parse(query),
109+
{}, # root object
110+
variables: { ... },
111+
context: { ... },
112+
tracers: [ ... ],
113+
).perform
114+
```

benchmark/run.rb

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
class GraphQLBenchmark
1313
DOCUMENT = GraphQL.parse(BASIC_DOCUMENT)
1414
CARDINAL_SCHEMA = SCHEMA
15+
CARDINAL_TRACER = GraphQL::Cardinal::Tracer.new
1516

1617
class Schema < GraphQL::Schema
1718
lazy_resolve(Proc, :call)
@@ -49,7 +50,8 @@ def benchmark_execution
4950
SCHEMA,
5051
BREADTH_RESOLVERS,
5152
DOCUMENT,
52-
data_source
53+
data_source,
54+
tracers: [CARDINAL_TRACER],
5355
).perform
5456
end
5557

@@ -81,7 +83,8 @@ def benchmark_lazy_execution
8183
SCHEMA,
8284
BREADTH_DEFERRED_RESOLVERS,
8385
DOCUMENT,
84-
data_source
86+
data_source,
87+
tracers: [CARDINAL_TRACER],
8588
).perform
8689
end
8790

@@ -110,7 +113,8 @@ def memory_profile
110113
SCHEMA,
111114
BREADTH_RESOLVERS,
112115
DOCUMENT,
113-
data_source
116+
data_source,
117+
tracers: [CARDINAL_TRACER],
114118
).perform
115119
end
116120

lib/graphql/cardinal.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ module Cardinal
1313
require_relative "cardinal/errors"
1414
require_relative "cardinal/promise"
1515
require_relative "cardinal/loader"
16+
require_relative "cardinal/tracer"
1617
require_relative "cardinal/field_resolvers"
1718
require_relative "cardinal/executor"
1819
require_relative "cardinal/version"

lib/graphql/cardinal/errors.rb

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,20 @@
55
module GraphQL
66
module Cardinal
77
class ExecutionError < StandardError
8-
attr_accessor :path
8+
attr_reader :path
99

10-
def initialize(message = "An unknown error occurred", path: nil)
10+
def initialize(message = "An unknown error occurred", path: nil, base: false)
1111
super(message)
1212
@path = path
13+
@base = base
14+
end
15+
16+
def base_error?
17+
@base
18+
end
19+
20+
def replace_path(new_path)
21+
@path = new_path
1322
end
1423

1524
def to_h
@@ -23,19 +32,16 @@ def to_h
2332
class AuthorizationError < ExecutionError
2433
attr_reader :type_name, :field_name
2534

26-
def initialize(message = nil, type_name: nil, field_name: nil, path: nil)
27-
super(message, path: path)
35+
def initialize(message = "Not authorized", type_name: nil, field_name: nil, path: nil, base: true)
36+
super(message, path: path, base: base)
2837
@type_name = type_name
2938
@field_name = field_name
3039
end
3140
end
3241

3342
class InvalidNullError < ExecutionError
34-
attr_reader :original_error
35-
36-
def initialize(message = "Cannot resolve value", path: nil, original_error: nil)
43+
def initialize(message = "Failed to resolve expected value", path: nil)
3744
super(message, path: path)
38-
@original_error = original_error
3945
end
4046
end
4147

lib/graphql/cardinal/executor.rb

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,35 @@
33
require_relative "./executor/execution_scope"
44
require_relative "./executor/execution_field"
55
require_relative "./executor/authorization"
6-
require_relative "./executor/tracer"
76
require_relative "./executor/hot_paths"
87
require_relative "./executor/response_hash"
9-
require_relative "./executor/response_shape"
8+
require_relative "./executor/error_formatting"
109

1110
module GraphQL
1211
module Cardinal
1312
class Executor
1413
include HotPaths
15-
include ResponseShape
14+
include ErrorFormatting
1615

1716
TYPENAME_FIELD = "__typename"
1817
TYPENAME_FIELD_RESOLVER = TypenameResolver.new
1918

2019
attr_reader :exec_count
2120

22-
def initialize(schema, resolvers, document, root_object)
21+
def initialize(schema, resolvers, document, root_object, variables: {}, context: {}, tracers: [])
2322
@query = GraphQL::Query.new(schema, document: document) # << for schema reference
2423
@resolvers = resolvers
2524
@document = document
2625
@root_object = root_object
27-
@tracer = Tracer.new
28-
@variables = {}
29-
@context = { query: @query }
26+
@tracers = tracers
27+
@variables = variables
28+
@context = context
3029
@data = {}
3130
@errors = []
32-
@inline_errors = false
3331
@path = []
3432
@exec_queue = []
3533
@exec_count = 0
34+
@context[:query] = @query
3635
end
3736

3837
def perform
@@ -70,9 +69,7 @@ def perform
7069
execute_scope(@exec_queue.shift) until @exec_queue.empty?
7170
end
7271

73-
response = {
74-
"data" => @inline_errors ? shape_response(@data) : @data,
75-
}
72+
response = { "data" => @errors.empty? ? @data : format_inline_errors(@data, @errors) }
7673
response["errors"] = @errors.map(&:to_h) unless @errors.empty?
7774
response
7875
end
@@ -102,22 +99,21 @@ def execute_scope(exec_scope)
10299
end
103100

104101
resolved_sources = if !field_resolver.authorized?(@context)
105-
@errors << AuthorizationError.new(type_name: parent_type.graphql_name, field_name: field_name, path: @path.dup)
106-
Array.new(parent_sources.length, nil)
102+
@errors << AuthorizationError.new(type_name: parent_type.graphql_name, field_name: field_name, path: @path.dup, base: true)
103+
Array.new(parent_sources.length, @errors.last)
107104
elsif !Authorization.can_access_type?(value_type, @context)
108-
@errors << AuthorizationError.new(type_name: value_type.graphql_name, path: @path.dup)
109-
Array.new(parent_sources.length, nil)
105+
@errors << AuthorizationError.new(type_name: value_type.graphql_name, path: @path.dup, base: true)
106+
Array.new(parent_sources.length, @errors.last)
110107
else
111108
begin
112-
@tracer&.before_resolve_field(parent_type, field_name, parent_sources.length, @context)
109+
@tracers.each { _1.before_resolve_field(parent_type, field_name, parent_sources.length, @context) }
113110
field_resolver.resolve(parent_sources, exec_field.arguments(@variables), @context, exec_scope)
114111
rescue StandardError => e
115-
raise e
116-
report_exception(e.message)
117-
@errors << InternalError.new(e.message, path: @path.dup)
118-
Array.new(parent_sources.length, nil)
112+
report_exception(error: e)
113+
@errors << InternalError.new(path: @path.dup, base: true)
114+
Array.new(parent_sources.length, @errors.last)
119115
ensure
120-
@tracer&.after_resolve_field(parent_type, field_name, parent_sources.length, @context)
116+
@tracers.each { _1.after_resolve_field(parent_type, field_name, parent_sources.length, @context) }
121117
@exec_count += 1
122118
end
123119
end
@@ -197,8 +193,8 @@ def resolve_execution_field(exec_scope, exec_field, resolved_sources, lazy_field
197193
next_sources_by_type.each do |impl_type, impl_type_sources|
198194
# check concrete type access only once per resolved type...
199195
unless Authorization.can_access_type?(impl_type, @context)
200-
@errors << AuthorizationError.new(type_name: impl_type.graphql_name, path: @path.dup)
201-
impl_type_sources = Array.new(impl_type_sources.length, nil)
196+
@errors << AuthorizationError.new(type_name: impl_type.graphql_name, path: @path.dup, base: true)
197+
impl_type_sources = Array.new(impl_type_sources.length, @errors.last)
202198
end
203199

204200
loader_group << ExecutionScope.new(
@@ -262,7 +258,7 @@ def execution_fields_by_key(parent_type, selections, map: Hash.new { |h, k| h[k]
262258
end
263259

264260
else
265-
raise DocumentError.new("selection node type")
261+
raise DocumentError.new("Invalid selection node type")
266262
end
267263
end
268264
map
@@ -290,8 +286,10 @@ def if_argument?(bool_arg)
290286
end
291287
end
292288

293-
def report_exception(message, path: @path.dup)
294-
# todo: hook up some kind of error reporting...
289+
def report_exception(message = nil, error: nil, path: @path.dup)
290+
# todo: add real error reporting...
291+
puts "Error at #{path.join(".")}: #{message || error&.message}"
292+
puts error.backtrace.join("\n") if error
295293
end
296294
end
297295
end

lib/graphql/cardinal/executor/response_shape.rb renamed to lib/graphql/cardinal/executor/error_formatting.rb

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,20 @@
33

44
module GraphQL::Cardinal
55
class Executor
6-
module ResponseShape
6+
module ErrorFormatting
77
private
88

9-
def shape_response(data)
10-
operation = @query.selected_operation
11-
parent_type = @query.root_type_for_operation(operation.operation_type)
12-
9+
def format_inline_errors(data, _errors)
10+
# todo: make this smarter to only traverse down actual error paths
1311
@path = []
14-
resolve_object_scope(data, parent_type, operation.selections)
12+
propagate_object_scope_errors(
13+
data,
14+
@query.root_type_for_operation(@query.selected_operation.operation_type),
15+
@query.selected_operation.selections,
16+
)
1517
end
1618

17-
def resolve_object_scope(raw_object, parent_type, selections)
19+
def propagate_object_scope_errors(raw_object, parent_type, selections)
1820
return nil if raw_object.nil?
1921

2022
selections.each do |node|
@@ -29,22 +31,15 @@ def resolve_object_scope(raw_object, parent_type, selections)
2931
raw_value = raw_object[field_name]
3032

3133
raw_object[field_name] = if raw_value.is_a?(ExecutionError)
32-
# capture errors encountered in the response with proper path
33-
@errors << if raw_value.is_a?(InvalidNullError) && raw_value.original_error
34-
raw_value.original_error.path = @path.dup
35-
raw_value.original_error
36-
else
37-
raw_value.path = @path.dup
38-
raw_value
39-
end
34+
raw_value.replace_path(@path.dup) unless raw_value.base_error?
4035
nil
4136
elsif node_type.list?
4237
node_type = node_type.of_type while node_type.non_null?
43-
resolve_list_scope(raw_value, node_type, node.selections)
38+
propagate_list_scope_errors(raw_value, node_type, node.selections)
4439
elsif named_type.kind.leaf?
4540
raw_value
4641
else
47-
resolve_object_scope(raw_value, named_type, node.selections)
42+
propagate_object_scope_errors(raw_value, named_type, node.selections)
4843
end
4944

5045
return nil if node_type.non_null? && raw_object[field_name].nil?
@@ -56,26 +51,26 @@ def resolve_object_scope(raw_object, parent_type, selections)
5651
fragment_type = node.type ? @query.get_type(node.type.name) : parent_type
5752
next unless typename_in_type?(raw_object.typename, fragment_type)
5853

59-
result = resolve_object_scope(raw_object, fragment_type, node.selections)
54+
result = propagate_object_scope_errors(raw_object, fragment_type, node.selections)
6055
return nil if result.nil?
6156

6257
when GraphQL::Language::Nodes::FragmentSpread
6358
fragment = @request.fragment_definitions[node.name]
6459
fragment_type = @query.get_type(fragment.type.name)
6560
next unless typename_in_type?(raw_object.typename, fragment_type)
6661

67-
result = resolve_object_scope(raw_object, fragment_type, fragment.selections)
62+
result = propagate_object_scope_errors(raw_object, fragment_type, fragment.selections)
6863
return nil if result.nil?
6964

7065
else
71-
raise DocumentError.new("selection node type")
66+
raise DocumentError.new("Invalid selection node type")
7267
end
7368
end
7469

7570
raw_object
7671
end
7772

78-
def resolve_list_scope(raw_list, current_node_type, selections)
73+
def propagate_list_scope_errors(raw_list, current_node_type, selections)
7974
return nil if raw_list.nil?
8075

8176
current_node_type = current_node_type.of_type while current_node_type.non_null?
@@ -88,11 +83,11 @@ def resolve_list_scope(raw_list, current_node_type, selections)
8883

8984
begin
9085
result = if next_node_type.list?
91-
resolve_list_scope(raw_list_element, next_node_type, selections)
86+
propagate_list_scope_errors(raw_list_element, next_node_type, selections)
9287
elsif named_type.kind.leaf?
9388
raw_list_element
9489
else
95-
resolve_object_scope(raw_list_element, named_type, selections)
90+
propagate_object_scope_errors(raw_list_element, named_type, selections)
9691
end
9792

9893
if result.nil?

0 commit comments

Comments
 (0)