FSharp.Data.GraphQL icon indicating copy to clipboard operation
FSharp.Data.GraphQL copied to clipboard

Use computation expressions for defining schema

Open Horusiath opened this issue 7 years ago • 6 comments

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?

Horusiath avatar Sep 27 '16 08:09 Horusiath

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)
            }
       }

OlegZee avatar Oct 07 '16 19:10 OlegZee

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

Horusiath avatar Oct 20 '16 08:10 Horusiath

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 avatar May 11 '17 11:05 Lenne231

@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 avatar May 12 '17 05:05 Horusiath

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.
  • Obsolete attribute could be leveraged to GraphQL schema itself - members marked as Obsolete would also appear as deprecated in 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 of ResolveFieldContext, 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?) or NoSchema (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 OtherType would generate actually 3 GQL types: BCase, CCase and union A = BCase | CCase - naming convention with Case suffix 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 be AnimalConnection. This conforms to Relay naming conventions.

Horusiath avatar May 29 '17 06:05 Horusiath

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.

Swoorup avatar Oct 07 '19 17:10 Swoorup