Skip to content

Commit 645967f

Browse files
authored
Ability to add errors to resolved fields (#512)
* Implemented the ability to add additional errors to resolved fields * Added tests to cover all cases of additional errors in `executeResolvers`/`resolveWith`
1 parent 717dafd commit 645967f

File tree

7 files changed

+208
-24
lines changed

7 files changed

+208
-24
lines changed

src/FSharp.Data.GraphQL.Server/ErrorTypes.fs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ namespace FSharp.Data.GraphQL
44
open System
55
open System.Collections.Generic
66
open FsToolkit.ErrorHandling
7+
open FSharp.Data.GraphQL.Errors
78
open FSharp.Data.GraphQL.Types
89

10+
911
type InputSource =
1012
| Variable of VarDef : VarDef
1113
| Argument of ArgDef : InputFieldDef
@@ -38,7 +40,7 @@ type internal CoercionError = {
3840
yield KeyValuePair (CustomErrorFields.Kind, this.ErrorKind |> box)
3941
match this.Path with
4042
| [] -> ()
41-
| path -> yield KeyValuePair (CustomErrorFields.Path, path |> List.rev |> box)
43+
| path -> yield KeyValuePair (CustomErrorFields.Path, normalizeErrorPath path)
4244

4345
match this.InputSource with
4446
| Variable varDef ->
@@ -87,7 +89,7 @@ type internal CoercionErrorWrapper = {
8789
yield KeyValuePair (CustomErrorFields.Kind, this.ErrorKind |> box)
8890
match this.Path with
8991
| [] -> ()
90-
| path -> yield KeyValuePair (CustomErrorFields.Path, path |> List.rev |> box)
92+
| path -> yield KeyValuePair (CustomErrorFields.Path, normalizeErrorPath path)
9193

9294
match this.InputSource with
9395
| Variable varDef ->

src/FSharp.Data.GraphQL.Server/ErrorsProcessing.fs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,6 @@ let splitSeqErrorsList (items: Result<'t, IGQLError list> list) =
114114
else
115115
let values = items |> getSeqValuesList
116116
Ok values
117+
118+
let internal normalizeErrorPath(fieldPath : FieldPath) =
119+
fieldPath |> List.rev

src/FSharp.Data.GraphQL.Server/Execution.fs

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ let private createFieldContext objdef argDefs ctx (info: ExecutionInfo) (path :
101101
Schema = ctx.Schema
102102
Args = args
103103
Variables = ctx.Variables
104-
Path = path |> List.rev }
104+
Path = normalizeErrorPath path }
105105
}
106106

107107
let private resolveField (execute: ExecuteField) (ctx: ResolveFieldContext) (parentValue: obj) =
@@ -136,7 +136,7 @@ let private raiseErrors errs = AsyncVal.wrap <| Error errs
136136
/// Given an error e, call ParseError in the given context's Schema to convert it into
137137
/// a list of one or more <see href="IGQLErrors">IGQLErrors</see>, then convert those
138138
/// to a list of <see href="GQLProblemDetails">GQLProblemDetails</see>.
139-
let private resolverError path ctx e = ctx.Schema.ParseError path e |> List.map (GQLProblemDetails.OfFieldExecutionError (path |> List.rev))
139+
let private resolverError path ctx e = ctx.Schema.ParseError path e |> List.map (GQLProblemDetails.OfFieldExecutionError (normalizeErrorPath path))
140140
// Helper functions for generating more specific <see href="GQLProblemDetails">GQLProblemDetails</see>.
141141
let private nullResolverError name path ctx = resolverError path ctx (GQLMessageException <| sprintf "Non-Null field %s resolved as a null!" name)
142142
let private coercionError value tyName path ctx = resolverError path ctx (GQLMessageException <| sprintf "Value '%O' could not be coerced to scalar %s" value tyName)
@@ -148,7 +148,7 @@ let private streamListError name tyName path ctx = resolverError path ctx (GQLMe
148148
let private resolved name v : AsyncVal<ResolverResult<KeyValuePair<string, obj>>> = KeyValuePair(name, box v) |> ResolverResult.data |> AsyncVal.wrap
149149

150150
let deferResults path (res : ResolverResult<obj>) : IObservable<GQLDeferredResponseContent> =
151-
let formattedPath = path |> List.rev
151+
let formattedPath = normalizeErrorPath path
152152
match res with
153153
| Ok (data, deferred, errs) ->
154154
let deferredData =
@@ -364,12 +364,22 @@ and private executeResolvers (ctx : ResolveFieldContext) (path : FieldPath) (par
364364
/// This handles all null resolver errors/error propagation.
365365
let resolveWith (ctx : ResolveFieldContext) (onSuccess : ResolveFieldContext -> FieldPath -> obj -> obj -> AsyncVal<ResolverResult<KeyValuePair<string, obj>>>) : AsyncVal<ResolverResult<KeyValuePair<string, obj>>> = asyncVal {
366366
let! resolved = value |> AsyncVal.rescue path ctx.Schema.ParseError
367+
let additionalErrs =
368+
match ctx.Context.Errors.TryGetValue ctx with
369+
| true, errors ->
370+
errors
371+
|> Seq.map (GQLProblemDetails.OfFieldExecutionError (normalizeErrorPath path))
372+
|> Seq.toList
373+
| false, _ -> []
367374
match resolved with
368-
| Error errs when ctx.ExecutionInfo.IsNullable -> return Ok (KeyValuePair(name, null), None, errs)
369-
| Ok None when ctx.ExecutionInfo.IsNullable -> return Ok (KeyValuePair(name, null), None, [])
370-
| Error errs -> return Error errs
371-
| Ok None -> return Error (nullResolverError name path ctx)
372-
| Ok (Some v) -> return! onSuccess ctx path parent v
375+
| Error errs when ctx.ExecutionInfo.IsNullable -> return Ok (KeyValuePair(name, null), None, errs @ additionalErrs)
376+
| Ok None when ctx.ExecutionInfo.IsNullable -> return Ok (KeyValuePair(name, null), None, additionalErrs)
377+
| Error errs -> return Error (errs @ additionalErrs)
378+
| Ok None -> return Error ((nullResolverError name path ctx) @ additionalErrs)
379+
| Ok (Some v) ->
380+
match! onSuccess ctx path parent v with
381+
| Ok (res, deferred, errs) -> return Ok (res, deferred, errs @ additionalErrs)
382+
| Error errs -> return Error (errs @ additionalErrs)
373383
}
374384

375385
match info.Kind, returnDef with
@@ -452,7 +462,7 @@ let private executeQueryOrMutation (resultSet: (string * ExecutionInfo) []) (ctx
452462
Schema = ctx.Schema
453463
Args = args
454464
Variables = ctx.Variables
455-
Path = path |> List.rev }
465+
Path = normalizeErrorPath path }
456466
let execute = ctx.FieldExecuteMap.GetExecute(ctx.ExecutionPlan.RootDef.Name, info.Definition.Name)
457467
asyncVal {
458468
let! result =

src/FSharp.Data.GraphQL.Server/Executor.fs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
namespace FSharp.Data.GraphQL
22

3+
open System.Collections.Concurrent
34
open System.Collections.Immutable
45
open System.Collections.Generic
56
open System.Runtime.InteropServices
@@ -108,6 +109,7 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s
108109
| Stream (stream) -> GQLExecutionResult.Stream (documentId, stream, res.Metadata)
109110
async {
110111
try
112+
let errors = ConcurrentDictionary<ResolveFieldContext, ConcurrentBag<IGQLError>>()
111113
let root = data |> Option.map box |> Option.toObj
112114
match coerceVariables executionPlan.Variables variables with
113115
| Error errs -> return prepareOutput (GQLExecutionResult.Error (documentId, errs, executionPlan.Metadata))
@@ -117,6 +119,7 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s
117119
RootValue = root
118120
ExecutionPlan = executionPlan
119121
Variables = variables
122+
Errors = errors
120123
FieldExecuteMap = fieldExecuteMap
121124
Metadata = executionPlan.Metadata }
122125
let! res = runMiddlewares (fun x -> x.ExecuteOperationAsync) executionCtx executeOperation |> AsyncVal.toAsync

src/FSharp.Data.GraphQL.Server/FSharp.Data.GraphQL.Server.fsproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@
3636
<ItemGroup>
3737
<Compile Include="Ast.fs" />
3838
<Compile Include="ReflectionHelper.fs" />
39+
<Compile Include="ErrorsProcessing.fs" />
3940
<Compile Include="ErrorTypes.fs" />
4041
<Compile Include="ErrorMessages.fs" />
41-
<Compile Include="ErrorsProcessing.fs" />
4242
<Compile Include="Exceptions.fs" />
4343
<Compile Include="TypeSystem.fs" />
4444
<Compile Include="Values.fs" />

src/FSharp.Data.GraphQL.Shared/TypeSystem.fs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ namespace FSharp.Data.GraphQL.Types
55
open System
66
open System.Reflection
77
open System.Collections
8+
open System.Collections.Concurrent
89
open System.Collections.Generic
910
open System.Collections.Immutable
1011
open System.Text.Json
12+
1113
open FSharp.Data.GraphQL
1214
open FSharp.Data.GraphQL.Ast
1315
open FSharp.Data.GraphQL.Extensions
@@ -890,11 +892,22 @@ and ExecutionContext = {
890892
ExecutionPlan : ExecutionPlan
891893
/// Collection of variables provided to execute current operation.
892894
Variables : ImmutableDictionary<string, obj>
895+
/// Collection of errors that occurred while executing current operation.
896+
Errors : ConcurrentDictionary<ResolveFieldContext, ConcurrentBag<IGQLError>>
893897
/// A map of all fields of the query and their respective execution operations.
894898
FieldExecuteMap : FieldExecuteMap
895899
/// A simple dictionary to hold metadata that can be used by execution customizations.
896900
Metadata : Metadata
897-
}
901+
} with
902+
903+
/// Remembers an error, so it can be included in the final response.
904+
member this.AddError (fieldContext, error : IGQLError) : unit =
905+
this.Errors.AddOrUpdate(
906+
fieldContext,
907+
addValueFactory = (fun _ -> ConcurrentBag (Seq.singleton error)),
908+
updateValueFactory = (fun _ bag -> bag.Add error; bag)
909+
)
910+
|> ignore
898911

899912
/// An execution context for the particular field, applied as the first
900913
/// parameter for target resolve function.
@@ -919,9 +932,17 @@ and ResolveFieldContext = {
919932
Path : FieldPath
920933
} with
921934

935+
/// Remembers an error, so it can be included in the final response.
936+
member this.AddError (error : IGQLError) =
937+
this.Context.AddError (this, error)
938+
939+
/// Remembers an error, so it can be included in the final response.
940+
member this.AddError (errorMessage : string) =
941+
this.Context.AddError (this, { new IGQLError with member _.Message = errorMessage } )
942+
922943
/// Tries to find an argument by provided name.
923-
member x.TryArg (name : string) : 't option =
924-
match Map.tryFind name x.Args with
944+
member this.TryArg (name : string) : 't option =
945+
match Map.tryFind name this.Args with
925946
| Some o -> Some (o :?> 't) // TODO: Use Convert.ChangeType
926947
| None -> None
927948

tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs

Lines changed: 154 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -394,28 +394,47 @@ let ``Execution when querying returns unique document id with response`` () =
394394
| response -> fail $"Expected a 'Direct' GQLResponse but got\n{response}"
395395

396396
type InnerNullableTest = { Kaboom : string }
397-
type NullableTest = { Inner : InnerNullableTest }
397+
type NullableTest = {
398+
Inner : InnerNullableTest
399+
InnerPartialSuccess : InnerNullableTest
400+
}
398401

399402
[<Fact>]
400403
let ``Execution handles errors: properly propagates errors`` () =
401-
let InnerObj =
404+
let InnerObjType =
402405
Define.Object<InnerNullableTest>(
403406
"Inner", [
404407
Define.Field("kaboom", StringType, fun _ x -> x.Kaboom)
405408
])
409+
let InnerPartialSuccessObjType =
410+
// executeResolvers/resolveWith, case 5
411+
let resolvePartialSuccess (ctx : ResolveFieldContext) (_ : InnerNullableTest) =
412+
ctx.AddError { new IGQLError with member _.Message = "Some non-critical error" }
413+
"Yes, Rico, Kaboom"
414+
Define.Object<InnerNullableTest>(
415+
"InnerPartialSuccess", [
416+
Define.Field("kaboom", StringType, resolvePartialSuccess)
417+
])
406418
let schema =
407419
Schema(Define.Object<NullableTest>(
408-
"Type", [
409-
Define.Field("inner", Nullable InnerObj, fun _ x -> Some x.Inner)
410-
]))
420+
"Type", [
421+
Define.Field("inner", Nullable InnerObjType, fun _ x -> Some x.Inner)
422+
Define.Field("partialSuccess", Nullable InnerPartialSuccessObjType, fun _ x -> Some x.InnerPartialSuccess)
423+
]))
411424
let expectedData =
412425
NameValueLookup.ofList [
413426
"inner", null
427+
"partialSuccess", NameValueLookup.ofList [
428+
"kaboom", "Yes, Rico, Kaboom"
429+
]
414430
]
415431
let expectedErrors = [
416432
GQLProblemDetails.CreateWithKind ("Non-Null field kaboom resolved as a null!", Execution, [ box "inner"; "kaboom" ])
433+
GQLProblemDetails.CreateWithKind ("Some non-critical error", Execution, [ box "partialSuccess"; "kaboom" ])
417434
]
418-
let result = sync <| Executor(schema).AsyncExecute("query Example { inner { kaboom } }", { Inner = { Kaboom = null } })
435+
let result =
436+
let variables = { Inner = { Kaboom = null }; InnerPartialSuccess = { Kaboom = "Yes, Rico, Kaboom" } }
437+
sync <| Executor(schema).AsyncExecute("query Example { inner { kaboom } partialSuccess { kaboom } }", variables)
419438
ensureDirect result <| fun data errors ->
420439
result.DocumentId |> notEquals Unchecked.defaultof<int>
421440
data |> equals (upcast expectedData)
@@ -425,9 +444,9 @@ let ``Execution handles errors: properly propagates errors`` () =
425444
let ``Execution handles errors: exceptions`` () =
426445
let schema =
427446
Schema(Define.Object<unit>(
428-
"Type", [
429-
Define.Field("a", StringType, fun _ _ -> failwith "Resolver Error!")
430-
]))
447+
"Type", [
448+
Define.Field("a", StringType, fun _ _ -> failwith "Resolver Error!")
449+
]))
431450
let expectedError = GQLProblemDetails.CreateWithKind ("Resolver Error!", Execution, [ box "a" ])
432451
let result = sync <| Executor(schema).AsyncExecute("query Test { a }", ())
433452
ensureRequestError result <| fun [ error ] -> error |> equals expectedError
@@ -458,3 +477,129 @@ let ``Execution handles errors: nullable list fields`` () =
458477
result.DocumentId |> notEquals Unchecked.defaultof<int>
459478
data |> equals (upcast expectedData)
460479
errors |> equals expectedErrors
480+
481+
482+
[<Fact>]
483+
let ``Execution handles errors: additional error added when exception is rised in a nullable field resolver`` () =
484+
let InnerNullableExceptionObjType =
485+
// executeResolvers/resolveWith, case 1
486+
let resolveWithException (ctx : ResolveFieldContext) (_ : InnerNullableTest) : string option =
487+
ctx.AddError { new IGQLError with member _.Message = "Non-critical error" }
488+
raise (System.Exception "Unexpected error")
489+
Define.Object<InnerNullableTest>(
490+
"InnerNullableException", [
491+
Define.Field("kaboom", Nullable StringType, resolve = resolveWithException)
492+
])
493+
let schema =
494+
Schema(Define.Object<NullableTest>(
495+
"Type", [
496+
Define.Field("inner", Nullable InnerNullableExceptionObjType, fun _ x -> Some x.Inner)
497+
]))
498+
let expectedData =
499+
NameValueLookup.ofList [
500+
"inner", NameValueLookup.ofList [
501+
"kaboom", null
502+
]
503+
]
504+
let expectedErrors =
505+
[
506+
GQLProblemDetails.CreateWithKind ("Unexpected error", Execution, [ box "inner"; "kaboom" ])
507+
GQLProblemDetails.CreateWithKind ("Non-critical error", Execution, [ box "inner"; "kaboom" ])
508+
]
509+
let result =
510+
let variables = { Inner = { Kaboom = null }; InnerPartialSuccess = { Kaboom = "Yes, Rico, Kaboom" } }
511+
sync <| Executor(schema).AsyncExecute("query Example { inner { kaboom } }", variables)
512+
ensureDirect result <| fun data errors ->
513+
result.DocumentId |> notEquals Unchecked.defaultof<int>
514+
data |> equals (upcast expectedData)
515+
errors |> equals expectedErrors
516+
517+
[<Fact>]
518+
let ``Execution handles errors: additional error added when None returned from a nullable field resolver`` () =
519+
let InnerNullableNoneObjType =
520+
// executeResolvers/resolveWith, case 2
521+
let resolveWithNone (ctx : ResolveFieldContext) (_ : InnerNullableTest) : string option =
522+
ctx.AddError { new IGQLError with member _.Message = "Non-critical error" }
523+
None
524+
Define.Object<InnerNullableTest>(
525+
"InnerNullableException", [
526+
Define.Field("kaboom", Nullable StringType, resolve = resolveWithNone)
527+
])
528+
let schema =
529+
Schema(Define.Object<NullableTest>(
530+
"Type", [
531+
Define.Field("inner", Nullable InnerNullableNoneObjType, fun _ x -> Some x.Inner)
532+
]))
533+
let expectedData =
534+
NameValueLookup.ofList [
535+
"inner", NameValueLookup.ofList [
536+
"kaboom", null
537+
]
538+
]
539+
let expectedErrors =
540+
[
541+
GQLProblemDetails.CreateWithKind ("Non-critical error", Execution, [ box "inner"; "kaboom" ])
542+
]
543+
let result =
544+
let variables = { Inner = { Kaboom = null }; InnerPartialSuccess = { Kaboom = "Yes, Rico, Kaboom" } }
545+
sync <| Executor(schema).AsyncExecute("query Example { inner { kaboom } }", variables)
546+
ensureDirect result <| fun data errors ->
547+
result.DocumentId |> notEquals Unchecked.defaultof<int>
548+
data |> equals (upcast expectedData)
549+
errors |> equals expectedErrors
550+
551+
[<Fact>]
552+
let ``Execution handles errors: additional error added when exception is rised in a non-nullable field resolver`` () =
553+
let InnerNonNullableExceptionObjType =
554+
// executeResolvers/resolveWith, case 3
555+
let resolveWithException (ctx : ResolveFieldContext) (_ : InnerNullableTest) : string =
556+
ctx.AddError { new IGQLError with member _.Message = "Non-critical error" }
557+
raise (System.Exception "Fatal error")
558+
Define.Object<InnerNullableTest>(
559+
"InnerNonNullableException", [
560+
Define.Field("kaboom", StringType, resolve = resolveWithException)
561+
])
562+
let schema =
563+
Schema(Define.Object<NullableTest>(
564+
"Type", [
565+
Define.Field("inner", InnerNonNullableExceptionObjType, fun _ x -> x.Inner)
566+
]))
567+
let expectedErrors =
568+
[
569+
GQLProblemDetails.CreateWithKind ("Fatal error", Execution, [ box "inner"; "kaboom" ])
570+
GQLProblemDetails.CreateWithKind ("Non-critical error", Execution, [ box "inner"; "kaboom" ])
571+
]
572+
let result =
573+
let variables = { Inner = { Kaboom = "Yes, Rico, Kaboom" }; InnerPartialSuccess = { Kaboom = "Yes, Rico, Kaboom" } }
574+
sync <| Executor(schema).AsyncExecute("query Example { inner { kaboom } }", variables)
575+
ensureRequestError result <| fun errors ->
576+
result.DocumentId |> notEquals Unchecked.defaultof<int>
577+
errors |> equals expectedErrors
578+
579+
[<Fact>]
580+
let ``Execution handles errors: additional error added and when null returned from a non-nullable field resolver`` () =
581+
let InnerNonNullableNullObjType =
582+
// executeResolvers/resolveWith, case 4
583+
let resolveWithNull (ctx : ResolveFieldContext) (_ : InnerNullableTest) : string =
584+
ctx.AddError { new IGQLError with member _.Message = "Non-critical error" }
585+
null
586+
Define.Object<InnerNullableTest>(
587+
"InnerNonNullableNull", [
588+
Define.Field("kaboom", StringType, resolveWithNull)
589+
])
590+
let schema =
591+
Schema(Define.Object<NullableTest>(
592+
"Type", [
593+
Define.Field("inner", InnerNonNullableNullObjType, fun _ x -> x.Inner)
594+
]))
595+
let expectedErrors =
596+
[
597+
GQLProblemDetails.CreateWithKind ("Non-Null field kaboom resolved as a null!", Execution, [ box "inner"; "kaboom" ])
598+
GQLProblemDetails.CreateWithKind ("Non-critical error", Execution, [ box "inner"; "kaboom" ])
599+
]
600+
let result =
601+
let variables = { Inner = { Kaboom = "Yes, Rico, Kaboom" }; InnerPartialSuccess = { Kaboom = "Yes, Rico, Kaboom" } }
602+
sync <| Executor(schema).AsyncExecute("query Example { inner { kaboom } }", variables)
603+
ensureRequestError result <| fun errors ->
604+
result.DocumentId |> notEquals Unchecked.defaultof<int>
605+
errors |> equals expectedErrors

0 commit comments

Comments
 (0)