fsprojects / FSharp.Data.GraphQL

FSharp implementation of Facebook GraphQL query language.
http://fsprojects.github.io/FSharp.Data.GraphQL/
MIT License
395 stars 72 forks source link

Use computation expressions for defining schema #86

Open Horusiath opened 7 years ago

Horusiath commented 7 years ago

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?

OlegZee commented 7 years ago

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)
            }
       }
Horusiath commented 7 years ago

I didn't saw that earlier. It looks nice indeed 👍

Lenne231 commented 7 years ago

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.

Horusiath commented 7 years ago

@Lenne231 there are several issues with following graphql-js desing:

  1. 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.
  2. 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.
  3. 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.
Horusiath commented 7 years ago

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:

Type system would be explicitly mapped between GQL type defs and F# types:

Swoorup commented 4 years ago

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.