3.0.0-rc.6 • Published 4 months ago

generate-graphql-client v3.0.0-rc.6

Weekly downloads
-
License
MIT
Repository
github
Last release
4 months ago

README

npm version

npm i generate-graphql-client --save-dev

This package is designed to generate TypeScript code from the GraphQL schema.

Starting from the root query and mutation, this tool generates TypeScript code for every type it finds. Moreover, it will generate several useful types for GraphQL query validation and a factory function to create a type-safe GraphQL client. Below is an example usage of the generated client.

import { client } from './client'

client.queries
  .user({
    $args: { id: '1001' },
    id: true,
    name: true,
    avatar: true
  })
  .then((user) => {
    // The type of user is `User | null`.
    console.log(user)
  })

In the above TypeScript code, we send a GraphQL query to the server. Both the argument and return type of the client.queries.user method are typed. The code sends the following GraphQL query to the server.

query {
  user(id: "1001") {
    id
    name
    avatar
  }
}

Table of contents:

Get started

Before we start, please make sure the dependencies have been installed.

npm i generate-graphql-query
npm i generate-graphql-client --save-dev

Then create a script and save it to <root>/scripts/graphql.mjs with the following content.

import { generate } from 'generate-graphql-client'

generate({
  files: [
    {
      endpoint: 'https://www.example.com/graphql',
      output: 'src/graphql/types.ts'
    }
  ]
})

!NOTE In the example above, we generate code from the endpoint. However, for security reasons, some endpoints require authentication to query the schema. In such cases, you can configure authorization headers. If an endpoint doesn’t expose an API for schema queries, you can generate code directly from the GraphQL schema.

Now we can run the script to generate the TypeScript code.

# Make sure we are in the <root> directory
node ./scripts/graphql.mjs

!NOTE You can also use the generate-graphql-client command to generate TypeScript code. To do that, just serialize the parameter we passed to the generate function with JSON format and save it to a file. Then run the following command to generate the code.

npx generate-graphql-client --config path/to/config.json

Please note that the relative paths in the JSON config is relative to the JSON file.

The generated code will be saved to src/graphql/types.ts. It contains all the types we found in the schema and exports a factory function as its default export, which we can use to create a GraphQL client.

In the following code, we will create a GraphQL client based on the generated file. Create a file named <root>/graphql/client.ts with the following content.

// We will use generate-graphql-query to generate the query.
import { generateQuery } from 'generate-graphql-query'

// Import the generated factory function.
import createGraphQLClient from './generated'

/**
 * Example of custom options.
 * You can change this type to whatever you want.
 */
export interface Options {
  cache?: 'cache-only' | 'network-only'
}

/**
 * Define a function to send GraphQL query.
 * In this example we will use the Fetch API.
 * You can use whatever you want, maybe axios for example.
 * You can also add authorization headers here if needed.
 */
const sendQuery = async (query: string, options?: Options) => {
  // Handle the options.
  console.log(options)

  return fetch('https://www.example.com/graphql', {
    method: 'post',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query })
  }).then((res) => res.json())
}

/**
 * Create the GraphQL client with the generated factory function.
 * The factory function accepts a async function as its parameter and
 * the async function accepts the following four parameters:
 *
 * - `type`: The operation type (query or mutation).
 * - `name`: The operations name. If `name` is `null`, means that the
 *    caller is `query()` or `mutation()`. If `name` is a string, means
 *    that the caller is `queries.xxx()` or `mutations.xxx()`.
 * - `payload`: If `name` is `null`, `payload` is the first parameter
 *    of `query()` or `mutation()`. If `name` is a string, `payload`
 *    is the first parameter of `queries.xxx()` or `mutations.xxx()`.
 * - `options`: Custom options. The second parameter of the client
 *    methods.
 */
export const client = createGraphQLClient<Options>(
  async (type, name, payload, options) => {
    // If name is `null`, means that the caller function is `query()` or
    // `mutation()` and `payload` is the first parameter of `query()` or
    // `mutation()`. In this case, we should return the entire response
    // json.
    if (name === null) {
      return sendQuery(generateQuery({ [type]: payload }), options)
    }

    // If `name` is a string, means that the caller function is
    // `queries.xxx()` or `mutations.xxx()` and `payload` is the first
    // parameter of `queries.xxx()` or `mutations.xxx()`. In this case,
    // we should return the expected data and throw error if something
    // went wrong.
    return sendQuery(
      generateQuery({ [type]: { [name]: payload } }),
      options
    ).then((res) => {
      if (res.errors?.[0]) {
        throw new Error(res.errors[0].message)
      }
      return res.data[name]
    })
  }
)

The client has the following properties.

  • query A function that can be used to send multiple queries.
  • queries An object containing all query methods the GraphQL API supports.
  • mutation A function that can be used to send multiple mutations.
  • mutations An object containing all mutation methods the GraphQL API supports.

!CAUTION If the GraphQL API does not provide any queries, query and queries will not be generated. And if the GraphQL API does not provide any mutations, mutation and mutations will not be generated.

Handle authorization when generating code from endpoints

For security reasons, some endpoints require authentication to query the schema. In such cases, we can add headers through the endpoint config. For example:

import { generate } from 'generate-graphql-client'

generate({
  files: [
    {
      endpoint: {
        url: 'https://www.example.com/graphql',
        headers: {
          Authorization: 'Bearer ***'
        }
      },
      output: 'src/graphql/types.ts'
    }
  ]
})

In the code above, we add authorization headers directly in the script file. While this works as expected, we may not want to commit these headers to our source tree. To prevent this, we can use the headerFile config to reference an external JSON file for the authorization headers and add that file to .gitignore. For example:

import { generate } from 'generate-graphql-client'

generate({
  files: [
    {
      endpoint: {
        url: 'https://www.example.com/graphql',
        headersFile: 'src/graphql/headers.json'
      },
      output: 'src/graphql/types.ts'
    }
  ]
})

The content of src/graphql/headers.json is:

{
  "Authorization": "Bearer ***"
}

In the code above, the content of src/graphql/headers.json will be used as headers for the endpoint. To prevent this file from being committed to the source tree, you should add it to .gitignore.

Generate code directly from the GraphQL schema

If the endpoint does not allow schema queries or if adding headers to the request is inconvenient. We can generate code from the GraphQL schema files.

First we need to convert the GraphQL schema files to a introspection JSON file.

Install the generate-graphql-introspection command:

npm i generate-graphql-introspection --save-dev

Generate the introspection file:

npx generate-graphql-introspection -s src/graphql/schema.graphql -o src/graphql/introspection.json

Generate code from the generated introspection file:

import { generate } from 'generate-graphql-client'

generate({
  files: [
    {
      filename: 'src/graphql/introspection.json',
      output: 'src/graphql/types.ts'
    }
  ]
})

!NOTE If your schema is divided into multiple .graphql files. You can use a glob to specify the schema path. For example:

npx generate-graphql-introspection -s "src/graphql/*.graphql" -o src/graphql/introspection.json

You can run npx generate-graphql-introspection -h for its docs.

Examples

This section provides examples demonstrating how to use the generated GraphQL client.

Basic usage

client.queries.user({
  $args: { id: '1001' },
  // Select the id field.
  id: true,
  // Instead of using `true` to select field.
  // We can also use `number` to select field for its shorter.
  name: 1,
  avatar: 1
})

Query interface

import { client } from './client'
import type { Order } from './types'

client.queries
  .node({
    $args: { id: '10002' },
    $on: {
      Order: {
        __typename: true,
        id: true,
        createdAt: true
      }
    }
  })
  .then((node) => {
    // The type of node is `Node | null`.
    if (node && node.__typename === 'Order') {
      const order = node as Order
      console.log(order)
    }
  })

The above code sends the following GraphQL query to the server.

query {
  node(id: "10002") {
    ... on Order {
      __typename
      id
      createdAt
    }
  }
}

Use alias

import { client } from './client'

// We need to specify the return type because we have
// changed the keys in the original return type.
client.queries
  .country<{
    country_code: string
    country_name: string
  } | null>({
    $args: { code: 'FR' },
    code: 'country_code',
    name: 'country_name'
  })
  .then((country) => {
    console.log(country)
  })

Here is a more complex example with client.query.

import { client } from './client'
import type { Country } from './types'

client
  .query<{
    country_fr: {
      country_code: string
      country_name: string
    } | null
    af_countries: Country[]
    as_countries: Country[]
  }>({
    country: {
      $alias: 'country_fr',
      $args: { code: 'FR' },
      code: 'country_code',
      name: { $alias: 'country_name' }
    },

    countries: [
      {
        $alias: 'af_countries',
        $args: {
          filter: {
            continent: { eq: 'AF' }
          }
        },
        code: true,
        name: true
      },
      {
        $alias: 'as_countries',
        $args: {
          filter: {
            continent: { eq: 'AS' }
          }
        },
        code: true,
        name: true
      }
    ]
  })
  .then((res) => {
    console.log(res.data?.country_fr)
    console.log(res.data?.af_countries)
    console.log(res.data?.as_countries)
  })

The above code sends the following GraphQL query to the server.

query {
  country_fr: country(code: "FR") {
    country_code: code
    country_name: name
  }
  af_countries: countries(filter: { continent: { eq: "AF" } }) {
    code
    name
  }
  as_countries: countries(filter: { continent: { eq: "AS" } }) {
    code
    name
  }
}

Use arguments

You can add arguments to fields using the $args keyword. The values in $args will be converted to corresponding types in GraphQL. The undefined values will be ignored. If an object contains nothing or all property values in it is undefined, the object will be ignored. If you want keep and empty object, please use the $keep flag.

client.queries.users({
  $args: {
    nameContains: 'a',
    verified: true,
    deleteAt: null,
    status: 1,

    hasFriendWith: {
      nameContains: 'b',
      deletedAt: null
    },

    role: undefined, // this field will be ignored
    hasRoleWidth: {} // this filed will be ignored
  },

  id: true,
  name: true
})

The above code sends the following GraphQL query to the server.

query {
  users(
    nameContains: "a"
    verified: true
    deleteAt: null
    status: 1
    hasFriendWith: { nameContains: "b", deletedAt: null }
  ) {
    id
    name
  }
}

Arguments for sub-fields

client.queries.country({
  $args: { code: 'FR' },

  // set arguments to the name field
  name: {
    $args: { lang: 'fr' }
  }
})

The above code sends the following GraphQL query to the server.

query {
  country(code: "FR") {
    name(lang: "fr")
  }
}

Empty objects in arguments

As we mentioned before, empty objects in arguments will be ignored. But sometime we do need passing empty object to the server, for example clearing all fields in a JSON field. To achieve this, we can use the $keep flag.

client.mutations.updateFile({
  $args: {
    id: '1',
    attrs: { $keep: true }
  },

  id: true,
  filename: true
})

The above code sends the following GraphQL query to the server.

mutation {
  updateFile(id: "1", attrs: {}) {
    id
    filename
  }
}

Use enum in arguments

Because enum cannot be quoted in GraphQL, we need to use the $enum flag to indicate that the argument should be treated as an enum. For example:

client.queries.todos({
  $args: {
    status: { $enum: 'IN_PROGRESS' }
  },
  id: 1,
  text: 1,
  createdAt: 1
})

The above code sends the following GraphQL query to the server.

query {
  todos(status: IN_PROGRESS) {
    id
    text
    createdAt
  }
}

Use directives

client.queries.todos({
  id: {
    // Use string to set directive
    $directives: '@skip(if: false)'
  },
  text: {
    // Use object to set directive
    $directives: {
      name: '@skip',
      args: { if: false }
    }
  },
  createdAt: {
    // Use array to set multiple directives
    $directives: [
      '@include(if: true)',
      {
        name: '@skip',
        args: { if: false }
      }
    ]
  }
})

The above code sends the following GraphQL query to the server.

query {
  todos {
    id @skip(if: false)
    text @skip(if: false)
    createdAt @include(if: true) @skip(if: false)
  }
}

Multiple fields in mutations

If a mutation contains multiple fields, the fields are executed one by one (top to bottom). To ensure they are ordered correctly in the generated query, use the $fields keyword to define them.

client.mutation({
  $fields: [
    {
      deleteStarship: {
        $alias: 'firstShip',
        $args: { id: '3001' }
      }
    },
    {
      deleteStarship: {
        $alias: 'secondShip',
        $args: { id: '3002' }
      }
    }
  ]
})

The above code sends the following GraphQL query to the server.

mutation {
  firstShip: deleteStarship(id: "3001")
  secondShip: deleteStarship(id: "3002")
}

Configuration

The configuration type is defined as follows.

export interface Configuration {
  /**
   * Global options. Default options for every schema files.
   */
  options?: Options

  /**
   * Schema files.
   */
  files?: SchemaFile[]
}

export interface SchemaFile {
  /**
   * The endpoint to fetch the schema introspection json file.
   * If `endpoint` is set, the `filename` option will be ignored.
   */
  endpoint?: string | Endpoint

  /**
   * Specify the file path to the introspection json file.
   * If `endpoint` is set, this option will be ignored.
   *
   * If the configuration is written in a JSON file,
   * the path is relative to that JSON file.
   */
  filename?: string

  /**
   * The output path of the generated typescript file.
   *
   * If the configuration is written in a JSON file,
   * the path is relative to that JSON file.
   */
  output: string

  /**
   * The options of the current schema file. If a option of `options`
   * is not set or set to `null`, the corresponding option in global
   * options will be used.
   */
  options?: Options

  /**
   * Skip this file.
   */
  skip?: boolean

  /**
   * By default, `options.renameTypes` will extend the `renameTypes`
   * defined in the global options. You can set `skipGlobalRenameTypes`
   * to avoid this.
   */
  skipGlobalRenameTypes?: boolean

  /**
   * By default, `options.scalarTypes` will extend the `scalarTypes`
   * defined in the global options. You can set `skipGlobalScalarTypes`
   * to avoid this.
   */
  skipGlobalScalarTypes?: boolean
}

export interface Endpoint {
  /**
   * The endpoint url.
   */
  url: string

  /**
   * Specify the request headers.
   */
  headers?: Record<string, any>

  /**
   * Path to a JSON file. The content will be used as `headers`.
   */
  headersFile?: string
}

export interface Options {
  /**
   * Specify the indent. The default value is 2 spaces.
   */
  indent?: string

  /**
   * Specify scalar types mapping. This mapping is used to map GraphQL
   * scalar types to TypeScript types. The default mapping is:
   *
   * ```json
   * {
   *   "ID": "string",
   *   "Int": "number",
   *   "Float": "number"
   * }
   * ```
   *
   * Please note that `String` will be replaced by `string` and `
   * Boolean` will be replaced by `boolean` directly (no type alias
   * will be generated).
   *
   * If the a scalar type is not specified, it will be mapped to
   * `unknown`.
   */
  scalarTypes?: Record<string, string | null | undefined>

  /**
   * Rename the type in the schema to a custom name. For example:
   *
   * ```json
   * {
   *   "Phone": "CellPhone"
   * }
   * ```
   *
   * The above config will rename the type `Phone` to `CellPhone`.
   *
   * Please note that the custom name cannot be used in the schema, and
   * cannot be the built-in names. Otherwise, an error will be thrown.
   *
   * Normally, you will not use this option. This option is designed to
   * fix conflicts. For example, a schema may define a scalar named
   * `BigInt`. The type name `BigInt` conflicts with `window.BigInt`.
   * The lint tool may complain about this. To avoid this, we can use
   * this option to rename `BigInt` to `_BigInt` in the generated code.
   */
  renameTypes?: Record<string, string | null | undefined>

  /**
   * The file headers.
   */
  headers?: string[]

  /**
   * Skip generating the generated message.
   */
  skipGeneratedMessage?: boolean

  /**
   * Skip wrapping enum in the args as `{ $enum: EnumType }`.
   */
  skipWrappingEnum?: boolean

  /**
   * Skip generating factory function.
   */
  skipFactory?: boolean

  /**
   * Skip generating `queries` object.
   */
  skipQueries?: boolean

  /**
   * Skip generating `mutations` object.
   */
  skipMutations?: boolean

  /**
   * By default the `__typename` field in the response objects is an
   * optional string. You can set this option to `true` to make it a
   * required string.
   */
  markTypenameAsRequired?: boolean
}
3.0.0-rc.6

4 months ago

3.0.0-rc.5

4 months ago

3.0.0-rc.4

4 months ago

3.0.0-rc.3

4 months ago

3.0.0-rc.2

4 months ago

3.0.0-rc.1

4 months ago

2.2.0

5 months ago

2.1.0

10 months ago

2.0.1

10 months ago

2.0.0-rc.2

10 months ago

2.0.0-rc.1

10 months ago

2.0.0

10 months ago

1.0.3

10 months ago

1.0.2

12 months ago

1.0.1

12 months ago

1.0.0

1 year ago

0.0.1-alpha.2

1 year ago

0.0.1-alpha.1

1 year ago