Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Discussion: Stronger typing for arguments in resolvers? #367

Open
njlr opened this issue May 18, 2022 · 10 comments
Open

Discussion: Stronger typing for arguments in resolvers? #367

njlr opened this issue May 18, 2022 · 10 comments

Comments

@njlr
Copy link
Contributor

njlr commented May 18, 2022

When writing resolver functions, my most common run-time errors are caused by unpacking the arguments incorrectly. This is because the API presents a Map<string, obj>, so it's easy to get the casting wrong!

I realized that when designing the schema, we actually have all of the type information already. The issue is that it's not passed to the resolve function!

Scroll down for a small example of the issue.

So, I set out to design an approach that carries the types from the argument list to the resolve function.

The idea is to:

  1. Provide type-safe building blocks for the built-in GraphQL types
  2. Provide functions for composing these building blocks as the user requires

Here is the main type definition:

type Args = Map<string, obj>

type Input<'t> =
  {
    InputFieldDefs : InputFieldDef list
    Extract : Args -> 't
  }

It wraps the type we already have (InputFieldDef) but adds a strongly-typed function for extracting the value during the resolve stage.

From this, we can create building blocks for built-in GraphQL types. For example, here is Int:

module Input =

  let int (name : string) (defaultValue : int option) (description : string option) : Input<int> =
    {
      InputFieldDefs =
        [
          Define.Input(name, Int, ?defaultValue=defaultValue, ?description=description)
        ]
      Extract =
        fun args ->
          match args |> Map.tryFind name with
          | Some o ->
            match o with
            | :? int as i -> i
            | _ -> failwith $"Argument \"{name}\" was not an int, it was a {o.GetType().Name}"
          | None ->
            match defaultValue with
            | Some i -> i
            | None -> failwith $"Argument \"{name}\" not found"
    }

We can combine two Input<_> objects together like this:

  let zip (a : Input<'a>) (b : Input<'b>) : Input<'a * 'b> =
    {
      InputFieldDefs = a.InputFieldDefs @ b.InputFieldDefs
      Extract =
        fun args ->
          let x = a.Extract args
          let y = b.Extract args
          x, y
    }

For example, if the resolve function expects an int and a string we can do:

Input.zip
  (Input.int "n" None None)
  (Input.string "s" None None)

And we can build a Computation Expression version of this too!

input {
  let! n = Input.int "n" None None
  and! s = Input.string "s" None None

  return n, s
}

The next step is to provide a function for creating a FieldDef that takes an Input<'t>:

module FieldDef =

  let define (name : string) (typeDef : #OutputDef<'t>) (input : Input<'arg>) resolve =
    Define.Field(
      name,
      typeDef,
      input.InputFieldDefs,
      (fun (ctx : ResolveFieldContext) x ->
        let arg = input.Extract ctx.Args

        resolve arg ctx x))

Here the resolve function takes 3 arguments instead of the usual 2. The first is the strongly-typed argument value.

And an Async version:

  let defineAsync(name : string) (typeDef : #OutputDef<'t>) (description : string) (input : Input<'arg>) resolve =
    Define.AsyncField(
      name,
      typeDef,
      description,
      input.InputFieldDefs,
      (fun (ctx : ResolveFieldContext) x ->
        async {
          let arg = input.Extract ctx.Args

          return! resolve arg ctx x
        }))

Here is a small schema that demonstrates how it all fits together:

open System

type ToDoItem =
  {
    ID : Guid
    Created : DateTime
    Title : string
    IsDone : bool
  }

type Root () =
  let mutable toDoItems = Map.empty

  member this.TryFetchToDoItem(id : Guid) =
    async {
      return Map.tryFind id toDoItems
    }

  member this.FetchToDoItems() =
    async {
      return
        toDoItems
        |> Map.toSeq
        |> Seq.map snd
        |> Seq.sortBy (fun x -> x.Created, x.ID)
        |> Seq.toList
    }

  member this.CreateToDoItem(title : string) =
    async {
      let toDoItem =
        {
          ID = Guid.NewGuid()
          Created = DateTime.UtcNow
          IsDone = false
          Title = title
        }

      toDoItems <- Map.add toDoItem.ID toDoItem toDoItems

      return toDoItem
    }

  member this.TryUpdateToDoItem(id : Guid, ?title : string, ?isDone : bool) =
    async {
      match Map.tryFind id toDoItems with
      | Some toDoItem ->
        let nextToDoItem =
          {
            toDoItem with
              Title = title |> Option.defaultValue toDoItem.Title
              IsDone = isDone |> Option.defaultValue toDoItem.IsDone
          }

        if toDoItem <> nextToDoItem then
          toDoItems <- Map.add id nextToDoItem toDoItems

        return Some nextToDoItem
      | None ->
        return None
    }

open FSharp.Data.GraphQL.Execution
open FSharp.Data.GraphQL.Types.SchemaDefinitions

let toDoItemType =
  Define.Object<ToDoItem>(
    "ToDoItem",
    [
      Define.Field("id", Guid, fun ctx x -> x.ID)
      Define.Field("created", String, fun ctx x -> x.Created.ToString("o"))
      Define.Field("title", String, fun ctx x -> x.Title)
      Define.Field("isDone", Boolean, fun ctx x -> x.IsDone)
    ]
  )

let queryType =
  Define.Object<Root>(
    "Query",
    [
      FieldDef.defineAsync
        "toDoItem"
        (Nullable toDoItemType)
        "Fetches a single to-do item"
        (Input.guid "id" None None)
        (fun g ctx (root : Root) ->
          root.TryFetchToDoItem(g))

      Define.AsyncField(
        "toDoItems",
        ListOf toDoItemType,
        "Fetches all to-do items",
        fun ctx (root : Root) ->
          root.FetchToDoItems())
    ]
  )

let mutationType =
  Define.Object<Root>(
    "Mutation",
    [
      FieldDef.defineAsync
        "createToDoItem"
        toDoItemType
        "Creates a new to-do item"
        (Input.string "title" None None)
        (fun title ctx (root : Root) ->
          root.CreateToDoItem(title))

      FieldDef.defineAsync
        "updateToDoItem"
        (Nullable toDoItemType)
        "Updates a to-do item"
        (input {
          let! id = Input.guid "id" None None
          and! title = Input.nullableString "title" None None
          and! isDone = Input.nullableBool "isDone" None None

          return id, title, isDone
        })
        (fun args ctx (root : Root) ->
          async {
            let id, title, isDone = args

            return! root.TryUpdateToDoItem(id, ?title=title, ?isDone=isDone)
          })
    ]
  )

let schema = Schema(queryType, mutationType)

I will attach a complete demo script that can run in FSI.

What does everyone think?
Could we build this into the library?

@njlr
Copy link
Contributor Author

njlr commented May 18, 2022

Full example. You can paste this into an .fsx file and run it with dotnet fsi.

#r "nuget: FSharp.Data.GraphQL.Server, 1.0.7"

open System
open System.Collections.Generic
open FSharp.Data.GraphQL
open FSharp.Data.GraphQL.Types

type Args = Map<string, obj>

type Input<'t> =
  {
    InputFieldDefs : InputFieldDef list
    Extract : Args -> 't
  }

module Input =

  let int (name : string) (defaultValue : int option) (description : string option) : Input<int> =
    {
      InputFieldDefs =
        [
          Define.Input(name, Int, ?defaultValue=defaultValue, ?description=description)
        ]
      Extract =
        fun args ->
          match args |> Map.tryFind name with
          | Some o ->
            match o with
            | :? int as i -> i
            | _ -> failwith $"Argument \"{name}\" was not an int, it was a {o.GetType().Name}"
          | None ->
            match defaultValue with
            | Some defaultValue -> defaultValue
            | None -> failwith $"Argument \"{name}\" not found"
    }

  let string (name : string) (defaultValue : string option) (description : string option) : Input<string> =
    {
      InputFieldDefs =
        [
          Define.Input(name, SchemaDefinitions.String, ?defaultValue=defaultValue, ?description=description)
        ]
      Extract =
        fun args ->
          match args |> Map.tryFind name with
          | Some o ->
            match o with
            | :? string as s -> s
            | _ -> failwith $"Argument \"{name}\" was not an string, it was a {o.GetType().Name}"
          | None ->
            match defaultValue with
            | Some defaultValue -> defaultValue
            | None -> failwith $"Argument \"{name}\" not found"
    }

  let optionalString (name : string) (defaultValue : (string option) option) (description : string option) : Input<(string option) option> =
    {
      InputFieldDefs =
        [
          Define.Input(name, SchemaDefinitions.Nullable SchemaDefinitions.String, ?defaultValue=defaultValue, ?description=description)
        ]
      Extract =
        fun args ->
          match args |> Map.tryFind name with
          | Some o ->
            match o with
            | null -> Some None
            | :? string as s -> Some (Some s)
            | :? (string option) as opt -> Some opt
            | _ -> failwith $"Argument \"{name}\" was not an string, it was a {o.GetType().Name}"
          | None ->
            match defaultValue with
            | Some defaultValue -> Some defaultValue
            | None -> None
    }

  let optionalBool (name : string) (defaultValue : (bool option) option) (description : string option) : Input<(bool option) option> =
    {
      InputFieldDefs =
        [
          Define.Input(name, SchemaDefinitions.Nullable SchemaDefinitions.Boolean, ?defaultValue=defaultValue, ?description=description)
        ]
      Extract =
        fun args ->
          match args |> Map.tryFind name with
          | Some o ->
            match o with
            | null -> Some None
            | :? bool as b -> Some (Some b)
            | :? (bool option) as opt -> Some opt
            | _ -> failwith $"Argument \"{name}\" was not an bool, it was a {o.GetType().Name}"
          | None ->
            match defaultValue with
            | Some defaultValue -> Some defaultValue
            | None -> None
    }

  let guid (name : string) (defaultValue : Guid option) (description : string option) : Input<Guid> =
    {
      InputFieldDefs =
        [
          Define.Input(name, SchemaDefinitions.Guid, ?defaultValue=defaultValue, ?description=description)
        ]
      Extract =
        fun args ->
          match args |> Map.tryFind name with
          | Some o ->
            match o with
            | :? Guid as g -> g
            | _ -> failwith $"Argument \"{name}\" was not an GUID, it was a {o.GetType().Name}"
          | None ->
            match defaultValue with
            | Some defaultValue -> defaultValue
            | None -> failwith $"Argument \"{name}\" not found"
    }

  let map (f : 't -> 'u) (i : Input<'t>) : Input<'u> =
    {
      InputFieldDefs = i.InputFieldDefs
      Extract = i.Extract >> f
    }

  let zip (a : Input<'a>) (b : Input<'b>) : Input<'a * 'b> =
    {
      InputFieldDefs = a.InputFieldDefs @ b.InputFieldDefs
      Extract =
        fun args ->
          let a = a.Extract args
          let b = b.Extract args
          a, b
    }

  let succeed (x : 't) : Input<'t> =
    {
      InputFieldDefs = []
      Extract = fun _ -> x
    }

  let none = succeed ()

module FieldDef =

  let define (name : string) (typeDef : #OutputDef<'t>) (input : Input<'arg>) resolve =
    Define.Field(
      name,
      typeDef,
      input.InputFieldDefs,
      fun (ctx : ResolveFieldContext) x ->
        let arg = input.Extract ctx.Args

        resolve arg ctx x)

  let defineAsync(name : string) (typeDef : #OutputDef<'t>) (description : string) (input : Input<'arg>) resolve =
    Define.AsyncField(
      name,
      typeDef,
      description,
      input.InputFieldDefs,
      fun (ctx : ResolveFieldContext) x ->
        async {
          let arg = input.Extract ctx.Args

          return! resolve arg ctx x
        })

[<AutoOpen>]
module ComputationExpression =

  type InputBuilder() =
    member this.MergeSources(a, b) =
      Input.zip a b

    member this.BindReturn(m, f) =
      Input.map f m

    member this.Return(x) =
      Input.succeed x

  let input = InputBuilder()



// Demo

open System

type ToDoItem =
  {
    ID : Guid
    Created : DateTime
    Title : string
    IsDone : bool
  }

type Root () =
  let mutable toDoItems = Map.empty

  member this.TryFetchToDoItem(id : Guid) =
    async {
      return Map.tryFind id toDoItems
    }

  member this.FetchToDoItems() =
    async {
      return
        toDoItems
        |> Map.toSeq
        |> Seq.map snd
        |> Seq.sortBy (fun x -> x.Created, x.ID)
        |> Seq.toList
    }

  member this.CreateToDoItem(title : string) =
    async {
      let toDoItem =
        {
          ID = Guid.NewGuid()
          Created = DateTime.UtcNow
          IsDone = false
          Title = title
        }

      toDoItems <- Map.add toDoItem.ID toDoItem toDoItems

      return toDoItem
    }

  member this.TryUpdateToDoItem(id : Guid, ?title : string, ?isDone : bool) =
    async {
      match Map.tryFind id toDoItems with
      | Some toDoItem ->
        let nextToDoItem =
          {
            toDoItem with
              Title = title |> Option.defaultValue toDoItem.Title
              IsDone = isDone |> Option.defaultValue toDoItem.IsDone
          }

        if toDoItem <> nextToDoItem then
          toDoItems <- Map.add id nextToDoItem toDoItems

        return Some nextToDoItem
      | None ->
        return None
    }

open FSharp.Data.GraphQL.Execution
open FSharp.Data.GraphQL.Types.SchemaDefinitions

let toDoItemType =
  Define.Object<ToDoItem>(
    "ToDoItem",
    [
      Define.Field("id", Guid, fun ctx x -> x.ID)
      Define.Field("created", String, fun ctx x -> x.Created.ToString("o"))
      Define.Field("title", String, fun ctx x -> x.Title)
      Define.Field("isDone", Boolean, fun ctx x -> x.IsDone)
    ]
  )

let queryType =
  Define.Object<Root>(
    "Query",
    [
      FieldDef.defineAsync
        "toDoItem"
        (Nullable toDoItemType)
        "Fetches a single to-do item"
        (Input.guid "id" None None)
        (fun g ctx (root : Root) ->
          root.TryFetchToDoItem(g))

      Define.AsyncField(
        "toDoItems",
        ListOf toDoItemType,
        "Fetches all to-do items",
        fun ctx (root : Root) ->
          root.FetchToDoItems())
    ]
  )

let mutationType =
  Define.Object<Root>(
    "Mutation",
    [
      FieldDef.defineAsync
        "createToDoItem"
        toDoItemType
        "Creates a new to-do item"
        (Input.string "title" None None)
        (fun title ctx (root : Root) ->
          root.CreateToDoItem(title))

      FieldDef.defineAsync
        "updateToDoItem"
        (Nullable toDoItemType)
        "Updates a to-do item"
        (input {
          let! id = Input.guid "id" None None
          and! title = Input.optionalString "title" None None
          and! isDone = Input.optionalBool "isDone" None None

          return id, title, isDone
        })
        (fun args ctx (root : Root) ->
          async {
            let id, title, isDone = args

            let title = Option.flatten title
            let isDone = Option.flatten isDone

            return! root.TryUpdateToDoItem(id, ?title=title, ?isDone=isDone)
          })
    ]
  )

let schema = Schema(queryType, mutationType)

let executor = Executor(schema)

let root = Root()

// Create a to-do item
let result1 =
  executor.AsyncExecute("mutation { createToDoItem(title: \"Write a great blog post\") { id created title isDone } }", root)
  |> Async.RunSynchronously

printfn "%A\n\n" result1

let id =
  match result1 with
  | Direct (output, _) ->
    output.["data"]
    |> fun x -> x :?> IDictionary<string, obj>
    |> fun x -> x.["createToDoItem"]
    |> fun x -> x :?> IDictionary<string, obj>
    |> fun x -> x.["id"]
    |> fun x -> x :?> Guid
  | x -> failwithf "Unexpected response: %A" x

// Show all to-dos
let result2 =
  executor.AsyncExecute("query { toDoItems { id created title isDone } }", root)
  |> Async.RunSynchronously

printfn "%A\n\n" result2

// Update a to-do item
let result3 =
  executor.AsyncExecute("mutation { updateToDoItem(id: \"" + string id + "\", title: \"Write a really great blog post\") { id created title isDone } }", root)
  |> Async.RunSynchronously

printfn "%A\n\n" result3

// Show all to-dos
let result4 =
  executor.AsyncExecute("query { toDoItems { id created title isDone } }", root)
  |> Async.RunSynchronously

printfn "%A\n\n" result4

@nikhedonia
Copy link
Collaborator

That looks like a great solution!
However I wonder if we can use some type provider magic to generate the right types to make this not necessary

@xperiandri
Copy link
Collaborator

Probably some erased type provider can be built that extends ResolveFieldContext

@xperiandri
Copy link
Collaborator

Maybe it is better to define an input as a record type but not as a list of definitions like now?
The schema will just walk through that record properties and create definitions.
And in runtime, we just deserialize the inputs JSON object right into that record.
Then the record comes to your resolver as an argument.

@njlr
Copy link
Contributor Author

njlr commented May 22, 2022

Maybe it is better to define an input as a record type but not as a list of definitions like now? The schema will just walk through that record properties and create definitions. And in runtime, we just deserialize the inputs JSON object right into that record. Then the record comes to your resolver as an argument.

I think that it is important for the user to be able to specify exactly what input types and names are put into the schema, and this should be somewhat independent of the underlying record type. I think reflection could be a convenient option (e.g. for the first pass at the schema), but it would not provide enough control in my view.


I realized my post does not explain the motivation for this very well, so here is a small example showing the problem with the current design:

#r "nuget: FSharp.Data.GraphQL.Server, 1.0.7"

// Domain

type Relation =
  | Friend
  | Foe

type Name = string

type Person =
  {
    Name : Name
    RelationshipTo : Name -> Relation
  }



// Schema

open FSharp.Data.GraphQL.Types

let relationType =
  Define.Enum(
    "Relation",
    [
      Define.EnumValue("FRIEND", Relation.Friend)
      Define.EnumValue("FOE", Relation.Foe)
    ]
  )

let personType =
  Define.Object<Person>(
    name = "Person",
    fields = [
      Define.Field("name", String, fun context x -> x.Name)

      Define.Field(
        "relationshipTo",
        relationType,
        [
          Define.Input("otherPerson", Int) // Mistake here, should be String
        ],
        fun (context : ResolveFieldContext) (x : Person) ->
          let otherPerson : string = context.Arg("otherPerson") // This is not really type-safe!

          x.RelationshipTo otherPerson)
    ])

@xperiandri
Copy link
Collaborator

Args come to you as a JSON object. In my opinion, Args map must be removed altogether.
And a developer must have 2 options:

  1. Get JsonObject and do whatever he wants (RawArgs)
  2. Get it automatically deserialized to the type (Args)
// Domain

type Relation =
  | Friend
  | Foe

type Name = string

type Person =
  {
    Name : Name
    RelationshipTo : Name -> Relation
  }

type RelationshipInput =
  // You can apply System.Text.Json.Serialization.JsonPropertyNameAttribute here
  { Person : int }


// Schema

open FSharp.Data.GraphQL.Types

let relationType =
  Define.Enum(
    "Relation",
    [
      Define.EnumValue("FRIEND", Relation.Friend)
      Define.EnumValue("FOE", Relation.Foe)
    ]
  )

let personType =
  Define.Object<Person>(
    name = "Person",
    fields = [
      Define.Field("name", String, fun context x -> x.Name)

      Define.Field(
        "relationshipTo",
        relationType,
        Define.Inputs<RelationshipInput>(), // You can use anonymous record here
        fun (context : ResolveFieldContext<RelationshipInput>) (x : Person) ->
          let { otherPerson = id } = context.Args // Error impossible!
          x.RelationshipTo otherPerson)
    ])

@xperiandri
Copy link
Collaborator

Input is always a combination of a name and a type Define.Input("otherPerson", Int). Nothing else is possible

@xperiandri
Copy link
Collaborator

Aha, maybe I missed the point that whole JSON object comes for whole query

@xperiandri
Copy link
Collaborator

But anyway getting all the args as a record/class looks much better to me than trying to get all that args manually.
What is the real case then you don't need all the input args?
And even in that case, you can define the record property as Lazy<T>

@xperiandri
Copy link
Collaborator

@mickhansen, @ivelten any thoughts from your sides?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants