Discussion: Stronger typing for arguments in resolvers?

Open
#367 10 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
5/5
Estimated time
Over a week
Newbie friendliness
30/100
Issue type
Feature
Clarity
Mostly clear
Activity status
Stale
Tech stack
graphql

Research direction

Start by reviewing the existing Args, InputFieldDef, Define.Field, and Define.AsyncField APIs, then compare them with the proposed Input and FieldDef.define examples. Done means the library has an agreed, type-safe approach for carrying resolver argument types through schema definitions, including synchronous and asynchronous fields, with the proposed examples validated.

Written by the indexing model from the issue text.

Description

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?

Dominant language
F#
Stars
406
Forks
74
Avg merge
1d 8h
Merged PRs (30d)
14

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from fsprojects/FSharp.Data.GraphQL

All issues in fsprojects/FSharp.Data.GraphQL

Similar issues

More Backend & API Design issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.