-
Notifications
You must be signed in to change notification settings - Fork 73
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
Comments
Full example. You can paste this into an #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
|
That looks like a great solution! |
Probably some erased type provider can be built that extends |
Maybe it is better to define an input as a record type but not as a list of definitions like now? |
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)
]) |
Args come to you as a JSON object. In my opinion, Args map must be removed altogether.
// 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)
]) |
Input is always a combination of a name and a type |
Aha, maybe I missed the point that whole JSON object comes for whole query |
But anyway getting all the args as a record/class looks much better to me than trying to get all that args manually. |
@mickhansen, @ivelten any thoughts from your sides? |
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:
Here is the main type definition:
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
:We can combine two
Input<_>
objects together like this:For example, if the resolve function expects an
int
and astring
we can do:And we can build a Computation Expression version of this too!
The next step is to provide a function for creating a
FieldDef
that takes anInput<'t>
:Here the
resolve
function takes 3 arguments instead of the usual 2. The first is the strongly-typed argument value.And an
Async
version:Here is a small schema that demonstrates how it all fits together:
I will attach a complete demo script that can run in FSI.
What does everyone think?
Could we build this into the library?
The text was updated successfully, but these errors were encountered: