diff --git a/src/FSharp.Data.GraphQL.Server/Values.fs b/src/FSharp.Data.GraphQL.Server/Values.fs index fbc646358..f0dbc4105 100644 --- a/src/FSharp.Data.GraphQL.Server/Values.fs +++ b/src/FSharp.Data.GraphQL.Server/Values.fs @@ -38,31 +38,36 @@ let rec internal compileByType (errMsg: string) (inputDef: InputDef): ExecuteInp | Scalar scalardef -> variableOrElse (scalardef.CoerceInput >> Option.toObj) | InputObject objdef -> - let objtype = objdef.Type - let ctor = ReflectionHelper.matchConstructor objtype (objdef.Fields |> Array.map (fun x -> x.Name)) - let mapper = - ctor.GetParameters() - |> Array.map(fun param -> - match objdef.Fields |> Array.tryFind(fun field -> field.Name = param.Name) with - | Some x -> x - | None -> - failwithf "Input object '%s' refers to type '%O', but constructor parameter '%s' doesn't match any of the defined input fields" objdef.Name objtype param.Name) - fun value variables -> - match value with - | ObjectValue props -> - let args = - mapper - |> Array.map (fun field -> - match Map.tryFind field.Name props with - | None -> null - | Some prop -> field.ExecuteInput prop variables) - let instance = ctor.Invoke(args) - instance - | Variable variableName -> - match Map.tryFind variableName variables with - | Some found -> found - | None -> null - | _ -> null + match objdef.CoerceInput with + | Some coerceInput -> + // Apply the user-provided coerce function, rather than attempt reflection + variableOrElse (coerceInput >> Option.toObj) + | None -> + let objtype = objdef.Type + let ctor = ReflectionHelper.matchConstructor objtype (objdef.Fields |> Array.map (fun x -> x.Name)) + let mapper = + ctor.GetParameters() + |> Array.map(fun param -> + match objdef.Fields |> Array.tryFind(fun field -> field.Name = param.Name) with + | Some x -> x + | None -> + failwithf "Input object '%s' refers to type '%O', but constructor parameter '%s' doesn't match any of the defined input fields" objdef.Name objtype param.Name) + fun value variables -> + match value with + | ObjectValue props -> + let args = + mapper + |> Array.map (fun field -> + match Map.tryFind field.Name props with + | None -> null + | Some prop -> field.ExecuteInput prop variables) + let instance = ctor.Invoke(args) + instance + | Variable variableName -> + match Map.tryFind variableName variables with + | Some found -> found + | None -> null + | _ -> null | List (Input innerdef) -> let inner = compileByType errMsg innerdef let cons, nil = ReflectionHelper.listOfType innerdef.Type @@ -153,17 +158,29 @@ let rec private coerceVariableValue isNullable typedef (vardef: VarDef) (input: | _ -> raise (GraphQLException <| errMsg + "Only Scalars, Nullables, Lists and InputObjects are valid type definitions.") and private coerceVariableInputObject (objdef) (vardef: VarDef) (input: obj) errMsg = - //TODO: this should be eventually coerced to complex object - match input with - | :? Map as map -> - let mapped = - objdef.Fields - |> Array.map (fun field -> - let valueFound = Map.tryFind field.Name map |> Option.toObj - (field.Name, coerceVariableValue false field.TypeDef vardef valueFound (errMsg + (sprintf "in field '%s': " field.Name)))) - |> Map.ofArray - upcast mapped - | _ -> input + match objdef.CoerceInput with + | Some coerceInput -> + // Apply the user-provided coerce function + match input with + | :? Value as v -> + coerceInput v |> Option.toObj + | _ -> failwithf "Expected an input Value but got %A" input + | None -> + match input with + | :? Map as map -> + let mapping = + objdef.Fields + |> Array.map (fun field -> + let valueFound = Map.tryFind field.Name map |> Option.toObj + let coercedValue = coerceVariableValue false field.TypeDef vardef valueFound (errMsg + (sprintf "in field '%s': " field.Name)) + + (field.Name, coercedValue)) + |> Map.ofSeq + + // TODO: Should we apply reflection tricks here? + + upcast mapping + | _ -> input let internal coerceVariable (vardef: VarDef) (inputs) = let vname = vardef.Name diff --git a/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs b/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs index df6dca4d3..fdf1b032d 100644 --- a/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs +++ b/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs @@ -1517,6 +1517,8 @@ and InputObjectDef = abstract Description : string option /// Collection of input object fields. abstract Fields : InputFieldDef [] + /// Optional function to customize default coercion behaviour + abstract CoerceInput : (Value -> obj option) option inherit NamedDef inherit InputDef end @@ -1530,13 +1532,22 @@ and InputObjectDefinition<'Val> = Description : string option /// Function used to define field inputs. It must be lazy /// in order to support self-referencing types. - FieldsFn : unit -> InputFieldDef [] } + FieldsFn : unit -> InputFieldDef [] + /// Optional function to customize default coercion behaviour + CoerceInput : (Value -> 'Val option) option } interface InputDef interface InputObjectDef with member x.Name = x.Name member x.Description = x.Description member x.Fields = x.FieldsFn() + member x.CoerceInput = + x.CoerceInput + |> Option.map + (fun coerceInputTyped -> + (fun v -> + coerceInputTyped v + |> Option.map box)) interface TypeDef<'Val> interface InputDef<'Val> @@ -2783,10 +2794,12 @@ module SchemaDefinitions = /// Function which generates a list of input fields defined by the current input object. Usefull, when object defines recursive dependencies. /// /// Optional input object description. Usefull for generating documentation. - static member InputObject(name : string, fieldsFn : unit -> InputFieldDef list, ?description : string) : InputObjectDefinition<'Out> = + /// Optional customization of input coercion + static member InputObject(name : string, fieldsFn : unit -> InputFieldDef list, ?description : string, ?coerceInput : _) : InputObjectDefinition<'Out> = { Name = name FieldsFn = fun () -> fieldsFn() |> List.toArray - Description = description } + Description = description + CoerceInput = coerceInput } /// /// Creates a custom GraphQL input object type. Unlike GraphQL objects, input objects are valid input types, @@ -2796,10 +2809,12 @@ module SchemaDefinitions = /// Type name. Must be unique in scope of the current schema. /// List of input fields defined by the current input object. /// Optional input object description. Usefull for generating documentation. - static member InputObject(name : string, fields : InputFieldDef list, ?description : string) : InputObjectDefinition<'Out> = + /// Optional customization of input coercion + static member InputObject(name : string, fields : InputFieldDef list, ?description : string, ?coerceInput : _) : InputObjectDefinition<'Out> = { Name = name Description = description - FieldsFn = fun () -> fields |> List.toArray } + FieldsFn = fun () -> fields |> List.toArray + CoerceInput = coerceInput } /// /// Creates the top level subscription object that holds all of the possible subscriptions as fields. diff --git a/tests/FSharp.Data.GraphQL.Tests/VariablesTests.fs b/tests/FSharp.Data.GraphQL.Tests/VariablesTests.fs index cbaaa8496..0ff6b4ac2 100644 --- a/tests/FSharp.Data.GraphQL.Tests/VariablesTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/VariablesTests.fs @@ -25,6 +25,7 @@ type TestInput = { c: string d: string option } + let TestInputObject = Define.InputObject( name = "TestInputObject", @@ -39,6 +40,7 @@ type TestNestedInput = { na: TestInput option nb: string } + let TestNestedInputObject = Define.InputObject( name = "TestNestedInputObject", @@ -51,7 +53,9 @@ let stringifyArg name (ctx: ResolveFieldContext) () = let arg = ctx.TryArg name |> Option.toObj toJson arg -let stringifyInput = stringifyArg "input" +let stringifyInput (ctx: ResolveFieldContext) () = + let inputArg : TestInput option = ctx.Arg "input" + toJson inputArg type EnumTestType = Foo | Bar @@ -64,6 +68,52 @@ let EnumTestType = , "Test enum" ) +let TestInputObjectWithCustomCoercion = + Define.InputObject( + name = "TestInputObject", + fields = [ + Define.Input("a", Nullable String) + Define.Input("b", Nullable(ListOf (Nullable String))) + Define.Input("c", String) + Define.Input("d", Nullable TestComplexScalar) + ], + coerceInput = + (fun value -> + // This can be greatly simplified by utility functions that the user can design + match value with + | ObjectValue props -> + try + let a = props |> Map.tryFind "a" |> Option.bind String.CoerceInput + + let b = + props + |> Map.tryFind "b" + |> Option.bind ( + function + | ListValue xs -> + xs + |> Seq.map ( + function + | StringValue s -> Some s + | NullValue -> None + ) + |> Some + | _ -> None + ) + + let c = props |> Map.find "c" |> String.CoerceInput |> Option.toObj + + let d = props |> Map.tryFind "d" |> Option.bind TestComplexScalar.CoerceInput + + Some { + a = a + b = b + c = c + d = d + } + with _ -> None + | _ -> None)) + let TestType = Define.Object( name = "TestType", @@ -78,6 +128,7 @@ let TestType = Define.Field("nnList", String, "", [ Define.Input("input", ListOf (Nullable String)) ], stringifyInput) Define.Field("listNN", String, "", [ Define.Input("input", Nullable (ListOf String)) ], stringifyInput) Define.Field("nnListNN", String, "", [ Define.Input("input", ListOf String) ], stringifyInput) + Define.Field("fieldWithObjectInputWithCustomCoercion", String, "", [ Define.Input("input", Nullable TestInputObjectWithCustomCoercion) ], stringifyInput) ]) let schema = Schema(TestType) @@ -543,3 +594,18 @@ let ``Execute handles enum input as variable`` () = data.["data"] |> equals (upcast expected) | _ -> fail "Expected Direct GQResponse" + +[] +let ``Execute handles objects with custom coercion`` () = + let ast = parse """{ fieldWithObjectInputWithCustomCoercion(input: {a: "foo", b: ["bar"], c: "baz"}) }""" + let actual = sync <| Executor(schema).AsyncExecute(ast) + let expected = + NameValueLookup.ofList [ + "fieldWithObjectInputWithCustomCoercion", upcast """{"a":"foo","b":["bar"],"c":"baz","d":null}""" + ] + + match actual with + | Direct(data, errors) -> + empty errors + data.["data"] |> equals (upcast expected) + | _ -> fail "Expected Direct GQResponse"