Discussion: Stronger typing for arguments in resolvers?
See original GitHub issueWhen 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:
- Provide type-safe building blocks for the built-in GraphQL types
- 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?
Issue Analytics
- State:
- Created a year ago
- Comments:10 (2 by maintainers)
Top GitHub Comments
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:
@mickhansen, @ivelten any thoughts from your sides?