FSharp.Data.GraphQL
FSharp.Data.GraphQL copied to clipboard
Use computation expressions for defining schema
We talked with @johnberzy-bazinga about idea of using cexprs for defining schemas, i.e instead of current:
let Person = Define.Object("Person", "description", [
Define.Field("firstName", String, "Person's first name", fun ctx p -> p.FirstName)
Define.Field("lastName", String, "Person's last name", fun ctx p -> p.LastName)
])
have something like:
let Person = outputObject "Person" {
description "description"
field "firstName" String "Person's first name" [] (fun _ p -> p.FirstName)
field "lastName" String "Person's last name" [] (fun _ p -> p.LastName)
}
This topic is to discuss about that. Is it even possible? Is it feasible?
This is the easiest part. Here's the builder:
type ObjectDefBuilder<'Val>(name : string) =
[<CustomOperation("description")>] member this.Description(f: ObjectDefinition<'Val>, value)
= {f with Description = Some value}
[<CustomOperation("addfield")>] member this.AddField(f: ObjectDefinition<'Val>, field: FieldDef<'Val>)
= {f with FieldsFn = lazy (f.FieldsFn.Value |> Map.add field.Name field)}
[<CustomOperation("field")>] member this.Field(f: ObjectDefinition<'Val>, name, typedef, description, args, resolve)
= this.Field(f, Define.Field(name, typedef, description , args, resolve))
member this.Zero() : ObjectDefinition<'Val> =
{
ObjectDefinition.Name = name
Description = None
FieldsFn = lazy Map.empty
Implements = [||]
IsTypeOf = None
}
member this.Yield(()) = this.Zero()
member this.Run(f: ObjectDefinition<'Val>): ObjectDef<'Val> = upcast f
member o.Bind(x,f) = f x
let outputObject<'Val> (name : string)
= new ObjectDefBuilder<'Val>(name)
// Sample
let MetadataType =
outputObject<IEnvelope WithContext> "Metadata" {
description "description"
addfield (Define.Field("createdBy", Int)) // the way to use Define.***
field "updatedAt" String "Last modified" [] (fun _ {data = e} -> e.UpdatedAt |> fmtDate)
}
I'd try to let partial field definitions such as:
let MetadataType =
outputObject<IEnvelope WithContext> "Metadata" {
description "description"
field "createdBy" Int
field "updatedAt" String {
description "Last modified"
resolve (fun _ {data = e} -> e.UpdatedAt |> fmtDate)
}
}
I didn't saw that earlier. It looks nice indeed 👍
Do we really need a custom way in F# to describe the schema? There is already the GraphQL schema language. What about writing the schema first and use a type provider to get a typed contract that we have to provide an implementation for.
Something like http://graphql.org/graphql-js/
var { graphql, buildSchema } = require('graphql');
// Construct a schema, using GraphQL schema language
var schema = buildSchema(`
type Query {
hello: String
}
`);
// The root provides a resolver function for each API endpoint
var root = {
hello: () => {
return 'Hello world!';
},
};
// Run the GraphQL query '{ hello }' and print out the response
graphql(schema, '{ hello }', root).then((response) => {
console.log(response);
});
but statically typed with a type provider.
@Lenne231 there are several issues with following graphql-js desing:
- You're basically defining schema twice (first time as string, second as object with resolver methods). While this may be solution for javascript - because there's no way to annotate GraphQL type system there - it looks counterproductive in typed languages.
- Since it's a string-based definition, you loose all of the power given you by the IDE. No syntax highlighting, no autocompletion. Even trivial things, like checking if you correctly closed all opened scopes and there's no brace missing, must be done by you. Type provider may validate that string, but it won't show you where in your string the bug is hidden.
- In case of typos in type names or identifiers, there's no way to guarantee that Type Provider will behave correctly. In optimistic case it won't compile, as under the typo it will find, that GraphQL schema is not total, and there are some not-described types. But again no help from the complier or IDE side there. In pessimistic case it will swallow that, and throw an error at runtime, when you'll try to call misspelled field.
I think, we could actually simplify current schema declarations, but idea would radically change the API. Here it is: while we allowed users to define resolvers automatically, this is not default option. By default we need to provide type defs for every type we want to include in the schema. The idea here is to only be explicit about root object type, and leave the rest to be inferred as type dependency tree.
Example:
[<Interface>]
type Animal =
abstract member Name: string option
type Dog =
{ Id: string
Name: string option
Barks: bool }
interface Animal with
member x.Name = x.Name
type Cat =
{ Id: string
Name: string option
Meows: bool }
interface Animal with
member x.Name = x.Name
type Root() =
[<Query>]
member x.Animals(ctx: GQLContext, first:int, ?after:string = ""): Animal seq = ???
[<Query("dog")>] // setup GraphQL field name explicitly
member x.GetDog(id: string): Dog option = ???
[<Mutation>]
member x.AddAnimal(input:AnimalInput): Animal = ???
[<Subscription>]
member x.AnimalAdded(id:string) : IObservable<Animal> = ???
let schema = Schema<Root>(options)
would be translated into following GraphQL schema definition (naming convention could be configured via options, camelCase by default):
interface Animal {
name: String
}
type Dog implements Animal {
id: String!
name: String
barks: Boolean
}
type Cat implements Animal {
id: String!
name: String
meows: Boolean
}
type Query {
animals($first: Int, $after: String = ""): [Animal]!
dog(id: String!): Dog
}
type Mutation {
addAnimal($input:AnimalInput): Animal!
}
type Subscription {
...
}
More description:
- Root objects (Query, Mutation, Subscription) would be recognized by corresponding attributes. Additional options (like explicit naming) could be added as attribute parameters.
Obsoleteattribute could be leveraged to GraphQL schema itself - members marked asObsoletewould also appear asdeprecatedin GraphQL schema.- Method parameters would be automatically resolved from parametrized fields in GraphQL query. Fields with default values would also appear as such in GQL schema.
- Only special field type is
GQLContext, an equivalent ofResolveFieldContext, representing contextual information about currently executed query segment itself. If method doesn't define such parameter, we don't need to generate context at all. - We would need some extra attributes i.e.
Description(unless you know how to generate graphql description directly from xml doc comments, maybe via Roslyn?) orNoSchema(to explicitly state that this field/method should not be exposed as part of GraphQL schema).
Type system would be explicitly mapped between GQL type defs and F# types:
- Some primitive types would be mapped up front explicitly.
- .NET interfaces → GraphQL interfaces.
- F# discriminated unions → GraphQL unions (mapping would be setup automatically, probably with some limitations). I.e. DU cases like
type A = | B of v1:int * v2:string | C of OtherTypewould generate actually 3 GQL types:BCase,CCaseandunion A = BCase | CCase- naming convention withCasesuffix would be applied to DU cases. - F# enums (so discriminated unions with numeric values) → GraphQL enums (value doesn't matter, as GQL requires enums to be represented as strings).
- All classes, structs and records as GraphQL objects.
- We'd need some limitations over generic outputs (GraphQL defines generics but only for lists and nullables). My proposition: allow generics with single generic type param, and generate a specialized type def every time it's used. Additionally use convention like: for generic type
Connection<Animal>GQL type def would beAnimalConnection. This conforms to Relay naming conventions.
Have to add that consuming the API in this form:
https://github.com/GT-CHaRM/CHaRM.Backend/blob/master/src/CHaRM.Backend/Schema/Item.fs
makes it much more simpler and easier to implement than the current form.