gram
TypeScript icon, indicating that this package has built-in type declarations

3.0.1 • Public • Published

GRAM

Node.js version NPM version semantic-release build with travis-ci tested with jest Coverage Status Maintainability Test Coverage Known Vulnerabilities

GRAphQL Model builder is a utility to generate all necessary GraphQL query, mutation and subscription Types. It enforces a specific structure for your schema.

Installation

npm i gram

Features

  • Automatic Interface generation
  • Strict schema layout
  • Easy adding of resolvers for attribute fields
  • Connect a service and go
  • Context dependent builds

Usage

Basic usage would let you generate a model by just defining it and adding some attributes. It requires you to have some service that provides necessary CRUD-style getter and setter functions (findMany, findOne, update, create, remove).

If you have a service called Animals you could create a schema like this:

  import { GraphQLString, GraphQLInt, GraphQLBoolean } from 'graphql'
  import { createSchemaBuilder } from 'gram'
  import { Animals } from './my-services/animals'
 
  const builder = createSchemaBuilder()
  const animal = builder.model('Animal', Animals)
 
  // animal type like 'dog', 'cat'
  // field is required
  animal.attr('type', GraphQLString).isNonNull()
  // animal name like 'Fluffy', 'Rex'
  // field is not required
  animal.attr('name', GraphQLString)
  // is it a tame animal
  animal.attr('tame', GraphQLBoolean)
  // age of the animal
  animal.attr('age', GraphQLInt)
 
  const schema = builder.build()

This will generate a graphql schema like:

  type Animal implements Node {
    id: ID
    createdAt: String
    updatedAt: String
    deletedAt: String
    type: String!
    name: String
    tame: Boolean
    age: Int
  }
 
  input AnimalFilter { … }
 
  input AnimalPage {
    limit: Int
    offset: Int
  }
 
  type Animals implements List {
    page: Page
    nodes: [Animal!]!
  }
 
  enum AnimalSortOrder { … }
 
  input AnimalWhere { … }
 
  input CreateAnimalData { … }
 
  interface List {
    page: Page
    nodes: [Node!]!
  }
 
  type Mutation {
    createAnimal(data: CreateAnimalData!): Animal
    updateAnimal(data: UpdateAnimalData!, where: AnimalWhere!): [Animal!]!
    deleteAnimals(where: AnimalWhere!): [Animal!]!
  }
 
  interface Node {
    id: ID
    createdAt: String
    updatedAt: String
    deletedAt: String
  }
 
  type Page {
    page: Int
    limit: Int
    offset: Int
  }
 
  type Query {
    getAnimal(where: AnimalWhere!, order: AnimalSortOrder): Animal
    getAnimals(order: AnimalSortOrder, page: AnimalPage, where: AnimalWhere!): Animals
  }
 
  type Subscription {
    onCreateAnimal: Animal!
    onUpdateAnimal: [Animal!]!
    onDeleteAnimals: [Animal!]!
  }
 
  input UpdateAnimalData { … }

As you can see, some types and interfaces were added. Gram is a little opinionated about Node, Page and List. It will generate the Node and List interfaces and apply them to you models by itself. Your models will have to implement the Node interface and the return value of the findMany method will have to return a Page type as the getAnimals query returns Animals which is of interface List.

Gram will automatically assume all implemented methods on the service will work. If you, for example, do not have a findMany method, Gram will remove the getAnimals method and just generate the rest.

If you want to generate more than one schema from your models, there will be a feature implemented in the near future to enable and disable parts depending on the context given in the build method.

Model Resolvers

To add a resolver to the model we can use the .resolve(() => Resolver) method on the model. In this example the database does not have an age column, and we need to calculate the age of the animal in the resolver.

  const animal = builder.model('Animal', Animals)
 
  // animal type like 'dog', 'cat'
  // field is required
  animal.attr('type', GraphQLString).isNonNull()
  // animal name like 'Fluffy', 'Rex'
  // field is not required
  animal.attr('name', GraphQLString)
  // is it a tame animal
  animal.attr('tame', GraphQLBoolean)
  // age of the animal
  animal.attr('age', GraphQLInt)
 
  animal.resolve(() => ({
    // calculate the age of the animal
    age: animal => Math.floor((Date.now() - animal.birthdate) / 1000 / 60 / 60 / 24 / 365)
  }))

Interfaces

Of course our Animal model is just an interface to help us build Cat and Dog models. We will convert our Animal into an interface and generate a Cat model that implements the interface. There will be no use for that type attribute any more, we will just skip it.

  const builder = createSchemaBuilder()
  const animal = builder.interface('Animal')
  // animal name like 'Fluffy', 'Rex'
  // field is not required
  animal.attr('name', GraphQLString)
  // is it a tame animal
  animal.attr('tame', GraphQLBoolean)
  // age of the animal
  animal.attr('age', GraphQLInt)
 
  const cat = builder.model('Cat', Animals)
  cat.interface('Animal')
  interface Animal {
    name: String
    tame: Boolean
  }
 
  type Cat implements Node & Animal {
    id: ID
    createdAt: Date
    updatedAt: Date
    deletedAt: Date
    name: String
    tame: Boolean
  }
 
  type Query {
    getCat(where: CatWhere!, order: CatSortOrder): Cat
    getCats(order: CatSortOrder, page: CatPage, where: CatWhere!): Cats
  }

As you can see it now added the attributes to the Cat model and we can now easily add more animal-type models to our system. We moved the service to the Cat model, which is not quite right. It would be best to have a Cat specific service, which will use the Animal service in turn, by adding some filters. But we will want to fetch any Animals as well.

 
  interface Animal {
    type: AnimalTypes
    name: string
    tame: boolean
    age: number
  }
 
  interface Cat extends Animal {
    type: AnimalTypes.Cat,
  }
  interface Dog extends Animal {
    type: AnimalTypes.Dog,
  }
 
  const Animals: Service<Animal> = {
    findOne: async ({ order, where }) => Magically.findData(where, order),
  }
 
  const Cats: Service<Cat> = {
    findOne: async ({ order, where }) => Animals.findOne({
      order, where: { ...where, type: AnimalTypes.Cat },
    }) as Promise<Cat>,
  }
  const Dogs: Service<Dog> = {
    findOne: async ({ order, where }) => Animals.findOne({
      order, where: { ...where, type: AnimalTypes.Dog },
    }) as Promise<Dog>,
  }
 
  // after defining all interfaces we setup the schema
 
  const builder = createSchemaBuilder()
  const animal = builder.interface('Animal', Animals)
  animal.attr('name', GraphQLString)
  animal.attr('tame', GraphQLBoolean)
  animal.attr('age', GraphQLInt)
 
  const cat = builder.model('Cat', Cats)
  cat.interface('Animal')
  const dog = builder.model('Dog', Dogs)
  dog.interface('Animal')
 
  interface Animal {
    name: String
    tame: Boolean
    age: Int
  }
 
  type Cat implements Node & Animal {
    id: ID
    createdAt: Date
    updatedAt: Date
    deletedAt: Date
    name: String
    tame: Boolean
    age: Int
  }
 
  type Dog implements Node & Animal {
    id: ID
    createdAt: Date
    updatedAt: Date
    deletedAt: Date
    name: String
    tame: Boolean
    age: Int
  }
 
  type Query {
    getAnimal(where: AnimalWhere!, order: AnimalSortOrder): Animal
    getCat(where: CatWhere!, order: CatSortOrder): Cat
    getDog(where: DogWhere!, order: DogSortOrder): Dog
  }

Now we can find any animal with a getAnimal(where: { name: "Fluffy" }) { ... on Dog { … }} or find our Dog directly getDog(where: { name: "Fluffy" }) { … }.

Scalars

For use as DateTime type inside this library @saeris/graphql-scalars is used. It is applied in the pagination system and allows more types to be setup, see the documentation for more information. Sadly this library has no typing.

To install another type, simply attach it to the schemabuilder.

  const EatingType = new GraphQLScalarType({
    name: 'EatingType',
    description: 'What is this animal eating',
    serialize: val => val,
  })
 
  const builder = createSchemaBuilder()
  builder.setScalar('EatingType', EatingType)
 
  const animal = builder.model('Animal', Animals)
 
  const DateTime = builder.getScalar('DateTime')
  // animal name like 'Fluffy', 'Rex'
  // field is not required
  animal.attr('name', GraphQLString)
  // is it a tame animal
  animal.attr('tame', GraphQLBoolean)
  // age of the animal
  animal.attr('dateOfBirth', DateTime)
  // feeding type of the animal
  animal.attr('feed', EatingType)
  """What is this animal eating"""
  scalar EatingType
 
  type Animal implements Node {
    name: String
    tame: Boolean
    dateOfBirth: DateTime
    feed: EatingType
    id: ID
    createdAt: DateTime
    updatedAt: DateTime
    deletedAt: DateTime
  }

There is no filter strategy setup for the new scalar type yet. To add this we will to setup this.

  const checkFn = isSpecificScalarType('EatingType')
  const filterFn = filters.joinFilters([
    filters.equals, // adds equal & not-equal filter
    filters.record, // adds in && not-in filters
  ])
  builder.addFilter(checkFn, filterFn)
  input AnimalFilter {
    {…}
    feed: EatingType
    feed_not: EatingType
    feed_in: [EatingType!]
    feed_not_in: [EatingType!]
    {…}
  }

Direct Queries & Mutations

With gram you could also just build up a graphQL schema by hand, it provides all necessary method to do so.

  const builder = createSchemaBuilder()
  builder.addQuery('random', GraphQLFloat, () => Math.random)
  type Query {
    random: Float
  }

GraphQL Types or String

Since version 2.1.2 it is now possible to set attribute types as strings, and not as GraphQLType objects.

  const builder = createSchemaBuilder()
  const animal = builder.model('Animal', AnimalService)
  // animal name like 'Fluffy', 'Rex'
  // field is required
  animal.attr('name', 'String!')
  // parents
  animal.attr('mother', 'Animal')
  animal.attr('father', 'Animal')
  // children
  animal.attr('children', '[Animal!]!')
type Animal implements Node {
  name: String!
  mother: Animal
  father: Animal
  children: [Animal!]!
  id: ID
  createdAt: DateTime
  updatedAt: DateTime
  deletedAt: DateTime
}

Context

The schema builder will allow you to create different schemas from one definition. When accessing a graphql endpoint we always need to keep in mind who and with what rights the access is done.

  type SchemaTypes = 'admin' | 'user'
  const builder = createSchemaBuilder<SchemaTypes>()
 
  const user = builder.model('User')
  user.attr('email', GraphQLString)
 
  builder.addQuery('context', GraphQLString, ({ context }) => () => context)
  builder.addQuery(
    'me',
    user,
    ({ context: schemaContext }) => (root, args, context?: GQLContext) => {
      if (!context) throw new Error('Need an authToken-context')
      if (schemaContext === 'user' && !context.authToken)
        throw new Error('Need an user:authToken')
      if (!context.authId) throw new Error('Need an authID')
      return {
        id: context.authId,
        email: schemaContext === 'admin' ? 'admin' : context.authToken,
      }
    },
  )
 
  const adminSchema = builder.build('admin')
  const userSchema = builder.build('user')
  const query = `{ context me { email }}`
 
  const adminResult = await graphql({
    schema: adminSchema,
    source: query,
    contextValue: {
      authId: 'AdminID',
    },
  })
  const userResult = await graphql({
    schema: userSchema,
    source: query,
    contextValue: {
      authId: 'AuthenticationID',
      authToken: 'My@Token.com',
    },
  })

This will generate 2 different graphql schema. The user-schema will be used for requests against the graphql system when the request is authenticated and identified to be a normal user. The admin-schema will then be used for requests from the system administrators. The build can also be generated as typeDefs and resolvers pair.

Contributing

If you want to help with this project, just leave a bug report or pull request. I'll try to come back to you as soon as possible

License

MIT

Readme

Keywords

none

Package Sidebar

Install

npm i gram

Weekly Downloads

1

Version

3.0.1

License

MIT

Unpacked Size

13.1 MB

Total Files

131

Last publish

Collaborators

  • steve.kite