Discussion: Stronger typing for arguments in resolvers?
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
- Domain
- api, backend-api-design
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:
- 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?
- 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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from fsprojects/FSharp.Data.GraphQL
-
Difficulty 4/5 3-5 days Newbie friendliness 52/100
-
Difficulty 3/5 1-2 days Newbie friendliness 45/100
-
fsprojects/FSharp.Data.GraphQL#573 · 1 reaction · 2 assignees ·
-
FR: Suave package Open
fsprojects/FSharp.Data.GraphQL#566 · 1 comment · 1 reaction · 2 assignees ·
-
Difficulty 5/5 Over a week Newbie friendliness 30/100
All issues in fsprojects/FSharp.Data.GraphQL
Similar issues
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
avniproject/avni-client#2135 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
use-agent-os/agent-os#3276 ·
-
enhancement
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
-
good first issue refactor
Difficulty 2/5 1-3 hours Newbie friendliness 72/100