0.2.3 • Published 4 years ago

graphql-auto-requester v0.2.3

Weekly downloads
5
License
MIT
Repository
github
Last release
4 years ago

graphql-auto-requester

graphql-auto-requester is a tool for making working with GraphQL requests simpler and more efficient by only requesting fields that are required at any given time.

An Example

Given the following schema:

type Num {
  value: Int!
  add(input: Int! = 1): Num!
  div(input: Int!): Num!
  mult(input: Int!): Num!
  sub(input: Int!): Num!
}

type Query {
  getNumber(input: Int!): Num!
}

You would usually have to manually define your query, which could get complex depending upon your use case. Using the requester we can traverse the schema and only request data when it is required. For example:

const requester = new GraphQLAutoRequester(schema)
const result = await requester.query.getNumber({ input: 100 })
  .add({ input: 10 })
  .mult({ input: 5 })
  .value
console.log(result) // 550

This is equivalent to the following GraphQL query:

query {
  getNumber(input: 100) {
    add(input: 10) {
      mult(input: 5) {
        value
      }
    }
  }
}

However, it is hard for us to implement a feature that explores the graph very deeply without struggling to maintain one query file that represents the superset of all options we may want to perform. This also means that, when we request data, we will often be asking for more data than we need. For an example, let's try to implement the Collatz conjecture against this API. To be able to compute the Collatz conjecture we may consider something like the following:

query ($input: Int!) {
  getNumber(input: $input) {
    div(input: 2) {
      value
    }
    mult(input: 3) {
      add(input: 1) {
        value
      }
    }
  }
}
const queryApiForValue = async (value) => { ... } // Implementation elided
const collatzByQuery = async (value) => {
  if (value === 1) {
    return 0
  }
  const result = await queryApiForValue(value)
  if (value % 2) {
    return 1 + await collatzByQuery(result.div.value)
  } else {
    return 1 + await collatzByQuery(result.mult.add.value)
  }
}

// Calling example
const steps = await collatzByQuery(100)
console.log(steps) // 25

There are some problems with this implementation, mainly that we are requesting the results of both the division and the addition when only one is ever required. We could make our queries smaller at the increased cost of code verbosity.

Alternatively, with graphql-auto-requester we could instead do:

const collatzAutoRequester = async (number) => {
  const value = await number.value
  if (value === 1) {
    return 0
  }
  if (value % 2 === 0) {
    return 1 + await collatzAutoRequester(number.div({ input: 2 }))
  } else {
    return 1 + await collatzAutoRequester(number.mult({ input: 3 }).add({ input: 1 }))
  }
}

// Calling example
const requester = new GraphQLAutoRequester(schema)
const steps = await collatzAutoRequester(requester.getNumber({ input: 100 }))
console.log(steps) // 25

Query Aggregation

That may not seem like a dramatic change, but when we start running queries in parallel we get to use the full power of graphql-auto-requester. Fields that are requested in the same tick of the JS event loop will be bundled in to a single request.

If we call the original function a couple of times in parallel:

const results = await Promise.all([
  collatzByQuery(1),
  collatzByQuery(2919),
  collatzByQuery(3711),
])
console.log(results) // [0, 216, 237]

Then we get results, but we also execute a query for every node visited for every instance, so 0 + 216 + 237 = 453 queries against our pretend API. If we compare that to the collatzAutoRequester version:

const requester = new GraphQLAutoRequester(schema)
const results = await Promise.all([
  collatzByQuery(requester.getNumber({ input: 1 })),
  collatzByQuery(requester.getNumber({ input: 2919 })),
  collatzByQuery(requester.getNumber({ input: 3711 })),
])
console.log(results) // [0, 216, 237]

We get the same result, but we only execute 238 queries. This is because we bundle together all the queries, so we only take the maximum step count (238) and add one because we make a call out for the initial value in this version.

In particular, this is very useful for using a GraphQL service as a datasource in another GraphQL service. If we were to make a new service that had the following schema:

type Num {
  value: Int!
  square: Num!
  mod(input: Int!): Num!
}

type Query {
  getNumberSquared(input: Int!): Num!
}

We could implement the following resolvers:

const resolvers = {
  Num: {
    // value is automatically implemented by normal GraphQL behaviour of looking on the parent for properties
    async square(num, _, { dataSources }) {
      const value = await num.value
      return dataSources.query.getNumber({ input: value }).mult({ input: value })
    },
    async mod(num, { input }, { dataSources }) {
      const value = await num.value
      return dataSources.query.getNumber({ input: value % input })
    }
  },
  Query: {
    getNumberSquared(_, { input }, { dataSources }) {
      // Note that this does not execute a query yet, so if the client does not select any fields that use this, no
      // call is made to the datasource
      return dataSources.query.getNumber({ input }).mult({ input })
    },
  },
}

Which provides us a custom GraphQL service backed by another GraphQL service. We can then make a query such as:

query {
  getNumberSquared(input: 2) {
    value
    square {
      value
      mod(input: 5) {
        value
      }
      square {
        value
        square {
          __typename
        }
      }
    }
    mod(input: 4) {
      value
      mod(input: 3) {
        value
      }
      square {
        value
      }
    }
  }
}

We would see 3 queries to the upstream GraphQL, as the mod and square branches are executed in parallel by the GraphQL executor on our new service. Importantly, the request for __typename does NOT result in a further request to the upstream service, as we can tell from the schema that this is valid and there is no need to execute a request for any subfields as __typename is simply resolved.

Delegation

As an optimization, if you are using graphql-auto-requester for resolving parts of a graphql query, you can use built in delegation to reduce the amount of calls out to an external service. For example, you could do the following:

import GraphQLAutoRequester, { delegate } from 'graphql-auto-requester'

// Somewhere else
const requester = new GraphQLAutoRequester(schema)

// And, in your resolvers you can delegate your queries like so:
const resolvers = {
  Query: {
    // ...
    // Delegate a root query
    delegatedQuery: (_, args, context, info) => {
      return delegate(requester.doAQuery, info)
    }
  },
  ArbitraryType: {
    // Delegate an arbitrary field at any point in your graph.
    delegateField: (instance, args, context, info) => {
      return delegate(
        requester.
          arbitraryTypeById({ instance.id }).
          delegatedField,
        info,
      )
    }
  },
}