6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

graphql-codegenのプラグインを自分で書いた ~ 副題:「GraphQLのクエリーを書けば、上手に型をつけてApolloClientでクエリを飛ばすやつを自動で生成したいと思ったので、Typescriptを書き出すJavascriptを書いた」

Last updated at Posted at 2019-07-08

概要

GraphQLクエリを書いたら、graphql-codegenが型を吐いてくれるけど、型をいちいち書くのもラクしたかったので、graphql-codegenのプラグインを自分で書いた。

できた奴

1.スキーマの例

こんなスキーマがあるとして、

schema.graphql
type Tweet {
  id: ID!
  body: String
  date: String
  Author: User
  Stats: Stat
}

type User {
  id: ID!
  username: String
  first_name: String
  last_name: String
  full_name: String
  name: String @deprecated
  avatar_url: String
}

type Stat {
  views: Int
  likes: Int
  retweets: Int
  responses: Int
}

type Notification {
  id: ID
  date: String
  type: String
}

type Meta {
  count: Int
}

type Comment {
  id: String
  content: String
}

type Query {
  Tweet(id: ID!): Tweet
  Tweets(limit: Int, skip: Int, sort_field: String, sort_order: String): [Tweet]
  TweetsMeta: Meta
  User(id: ID!): User
  Notifications(limit: Int): [Notification]
  NotificationsMeta: Meta
}

type Mutation {
  createTweet(body: String): Tweet
  deleteTweet(id: ID!): Tweet
  markTweetRead(id: ID!): Boolean
}

type Subscription {
  commentAdded(repoFullName: String!): Comment
}

参考: https://github.com/marmelab/GraphQL-example/blob/master/schema.graphql

2.クエリの例

こんなクエリを書いたgraphqlファイルを作るとする。

tweet.graphql
query TweetMeta {
  TweetsMeta {
    count
  }
}

query Tweet($id: ID!) {
  Tweet(id: $id) {
    body
    date
    Author {
      full_name
    }
  }
}

mutation CreateTweet($body: String) {
  createTweet(body: $body) {
    id
  }
}

subscription SubscComment($repoFullName: String!) {
  commentAdded(repoFullName: $repoFullName) {
    id
    content
  }
}

3. 生成例

するとこんな感じにtsを吐いてくれる。(監視対象の.graphqlごとにクラスを生やしてくれる)

generated/class.ts
import * as Type from "./types";
import * as Node from "./nodes";
import * as ApolloType from "apollo-client";
import ApolloClient from "apollo-client";
export interface ClientClass {
  readonly client: ApolloClient<any>;
}

export class TweetClient implements ClientClass {
  constructor(readonly client: ApolloClient<any>) {}

  tweetMeta = (
    options?: Omit<
      ApolloType.QueryOptions<Type.TweetMetaQueryVariables>,
      "query"
    >
  ) =>
    this.client.query<Type.TweetMetaQuery, Type.TweetMetaQueryVariables>({
      ...options,
      ...{ query: Node.TweetMeta }
    });

  tweet = (
    options?: Omit<ApolloType.QueryOptions<Type.TweetQueryVariables>, "query">
  ) =>
    this.client.query<Type.TweetQuery, Type.TweetQueryVariables>({
      ...options,
      ...{ query: Node.Tweet }
    });

  createTweet = (
    options?: Omit<
      ApolloType.MutationOptions<
        Type.CreateTweetMutation,
        Type.CreateTweetMutationVariables
      >,
      "mutation"
    >
  ) =>
    this.client.mutate<
      Type.CreateTweetMutation,
      Type.CreateTweetMutationVariables
    >({ ...options, ...{ mutation: Node.CreateTweet } });

  subscComment = (
    options?: Omit<
      ApolloType.SubscriptionOptions<Type.SubscCommentSubscriptionVariables>,
      "query"
    >
  ) =>
    this.client.subscribe<
      Type.SubscCommentSubscription,
      Type.SubscCommentSubscriptionVariables
    >({ ...options, ...{ query: Node.SubscComment } });
}

4. 使い道

gqlタグとか書かずに、補完バリバリで気持ちよく書ける。嬉しい。

main.ts
import { TweetClient } from "./generated/class";
import ApolloClient from "apollo-boost";
import "isomorphic-fetch";

const client = new TweetClient(
  new ApolloClient({ uri: "http://localhost:4000/" })
);

async function main() {
  const hoge = await client.tweetMeta();
  console.log(JSON.stringify(hoge.data.TweetsMeta));

  const huga = await client.createTweet({
    variables: {
      body: "aaa"
    }
  });
  //dataはnullチェックしないと怒られる
  console.log(JSON.stringify(huga.data && huga.data.createTweet));

  const piyo = await client.tweet({ variables: { id: "hoga" } });
  console.log(JSON.stringify(piyo.data));
}

main();

作り方

環境

yarnなり、npmでよしなに入れる。

package.json
{
  "scripts": {
    "codegen": "graphql-codegen --config codegen.yml --watch",
    "server": "node server.js"
  },
  "devDependencies": {
    "@graphql-codegen/cli": "^1.3.1",
    "@graphql-codegen/typescript": "^1.3.1",
    "@graphql-codegen/typescript-document-nodes": "^1.3.1-alpha-21fe4751.62",
    "@graphql-codegen/typescript-operations": "1.3.1",
    "@types/graphql": "^14.2.2",
    "apollo-client": "^2.6.3",
    "change-case": "^3.1.0",
    "graphql": "^14.4.2",
    "prettier": "^1.18.2",
    "typescript": "^3.5.2"
  },
  "dependencies": {
    "apollo-boost": "^0.4.3",
    "apollo-server": "^2.6.7",
    "isomorphic-fetch": "^2.2.1"
  }
}

適当にモックサーバを立てる。

ApolloServerのmockをtrueにすれば適当にサーバが立つ。

モックサーバーの立て方の例
server.js
const { ApolloServer, gql } = require("apollo-server");

const typeDefs = `
type Tweet {
  id: ID!
  body: String
  date: String
  Author: User
  Stats: Stat
}

type User {
  id: ID!
  username: String
  first_name: String
  last_name: String
  full_name: String
  name: String @deprecated
  avatar_url: String
}

type Stat {
  views: Int
  likes: Int
  retweets: Int
  responses: Int
}

type Notification {
  id: ID
  date: String
  type: String
}

type Meta {
  count: Int
}

type Comment {
  id: String
  content: String
}

type Query {
  Tweet(id: ID!): Tweet
  Tweets(limit: Int, skip: Int, sort_field: String, sort_order: String): [Tweet]
  TweetsMeta: Meta
  User(id: ID!): User
  Notifications(limit: Int): [Notification]
  NotificationsMeta: Meta
}

type Mutation {
  createTweet(body: String): Tweet
  deleteTweet(id: ID!): Tweet
  markTweetRead(id: ID!): Boolean
}

type Subscription {
  commentAdded(repoFullName: String!): Comment
}

`;

const server = new ApolloServer({
  typeDefs,
  mocks: true
});

server.listen().then(({ url }) => {
  console.log(`Server ready at ${url}`);
});

起動

terminal
yarn server

Graphql-codegenを走らす

graphql-codegenの設定ファイルを書く。

codegen.yaml
overwrite: true
schema: "http://localhost:4000" # サーバのアドレス。勝手にスキーマを呼んでくれる
documents: "queries/*.graphql" # 監視対象 ここのファイルに更新があれば自動的に型を作成
generates:
  ./generated/types.ts:
    plugins:
      - typescript
      - typescript-operations
  ./generated/nodes.ts:
    plugins:
      - typescript-document-nodes

監視対象として、さっきの例のtweet.graphqlqueries/に作っておく。

そしてgraphql-codegenを走らせる。

terminal
yarn codegen

すると./generated/types.tsに型を吐くし、./generated/nodes.tsにクエリにgqlタグ付けた定数が吐かれる。また、これらはqueriesのファイルの変化に応じて逐次更新される。

なお、"codegen": "graphql-codegen --config codegen.yml --watch"からwatchオプションを外せば、自動更新は無効になる。

ts:generated/nodes.ts(自動生成)
generated/nodes.ts(自動生成)
import { DocumentNode } from "graphql";
import gql from "graphql-tag";

export const TweetMeta: DocumentNode = gql`
  query TweetMeta {
    TweetsMeta {
      count
    }
  }
`;

export const Tweet: DocumentNode = gql`
  query Tweet($id: ID!) {
    Tweet(id: $id) {
      body
      date
      Author {
        full_name
      }
    }
  }
`;

export const CreateTweet: DocumentNode = gql`
  mutation CreateTweet($body: String) {
    createTweet(body: $body) {
      id
    }
  }
`;

export const SubscComment: DocumentNode = gql`
  subscription SubscComment($repoFullName: String!) {
    commentAdded(repoFullName: $repoFullName) {
      id
      content
    }
  }
`;

ts:generated/types.ts(自動生成)
generated/types.ts(自動生成)
export type Maybe<T> = T | null;
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
  ID: string;
  String: string;
  Boolean: boolean;
  Int: number;
  Float: number;
  Upload: any;
};

export enum CacheControlScope {
  Public = "PUBLIC",
  Private = "PRIVATE"
}

export type Comment = {
  __typename?: "Comment";
  id?: Maybe<Scalars["String"]>;
  content?: Maybe<Scalars["String"]>;
};

export type Meta = {
  __typename?: "Meta";
  count?: Maybe<Scalars["Int"]>;
};

export type Mutation = {
  __typename?: "Mutation";
  createTweet?: Maybe<Tweet>;
  deleteTweet?: Maybe<Tweet>;
  markTweetRead?: Maybe<Scalars["Boolean"]>;
};

export type MutationCreateTweetArgs = {
  body?: Maybe<Scalars["String"]>;
};

export type MutationDeleteTweetArgs = {
  id: Scalars["ID"];
};

export type MutationMarkTweetReadArgs = {
  id: Scalars["ID"];
};

export type Notification = {
  __typename?: "Notification";
  id?: Maybe<Scalars["ID"]>;
  date?: Maybe<Scalars["String"]>;
  type?: Maybe<Scalars["String"]>;
};

export type Query = {
  __typename?: "Query";
  Tweet?: Maybe<Tweet>;
  Tweets?: Maybe<Array<Maybe<Tweet>>>;
  TweetsMeta?: Maybe<Meta>;
  User?: Maybe<User>;
  Notifications?: Maybe<Array<Maybe<Notification>>>;
  NotificationsMeta?: Maybe<Meta>;
};

export type QueryTweetArgs = {
  id: Scalars["ID"];
};

export type QueryTweetsArgs = {
  limit?: Maybe<Scalars["Int"]>;
  skip?: Maybe<Scalars["Int"]>;
  sort_field?: Maybe<Scalars["String"]>;
  sort_order?: Maybe<Scalars["String"]>;
};

export type QueryUserArgs = {
  id: Scalars["ID"];
};

export type QueryNotificationsArgs = {
  limit?: Maybe<Scalars["Int"]>;
};

export type Stat = {
  __typename?: "Stat";
  views?: Maybe<Scalars["Int"]>;
  likes?: Maybe<Scalars["Int"]>;
  retweets?: Maybe<Scalars["Int"]>;
  responses?: Maybe<Scalars["Int"]>;
};

export type Subscription = {
  __typename?: "Subscription";
  commentAdded?: Maybe<Comment>;
};

export type SubscriptionCommentAddedArgs = {
  repoFullName: Scalars["String"];
};

export type Tweet = {
  __typename?: "Tweet";
  id: Scalars["ID"];
  body?: Maybe<Scalars["String"]>;
  date?: Maybe<Scalars["String"]>;
  Author?: Maybe<User>;
  Stats?: Maybe<Stat>;
};

export type User = {
  __typename?: "User";
  id: Scalars["ID"];
  username?: Maybe<Scalars["String"]>;
  first_name?: Maybe<Scalars["String"]>;
  last_name?: Maybe<Scalars["String"]>;
  full_name?: Maybe<Scalars["String"]>;
  name?: Maybe<Scalars["String"]>;
  avatar_url?: Maybe<Scalars["String"]>;
};
export type TweetMetaQueryVariables = {};

export type TweetMetaQuery = { __typename?: "Query" } & {
  TweetsMeta: Maybe<{ __typename?: "Meta" } & Pick<Meta, "count">>;
};

export type TweetQueryVariables = {
  id: Scalars["ID"];
};

export type TweetQuery = { __typename?: "Query" } & {
  Tweet: Maybe<
    { __typename?: "Tweet" } & Pick<Tweet, "body" | "date"> & {
        Author: Maybe<{ __typename?: "User" } & Pick<User, "full_name">>;
      }
  >;
};

export type CreateTweetMutationVariables = {
  body?: Maybe<Scalars["String"]>;
};

export type CreateTweetMutation = { __typename?: "Mutation" } & {
  createTweet: Maybe<{ __typename?: "Tweet" } & Pick<Tweet, "id">>;
};

export type SubscCommentSubscriptionVariables = {
  repoFullName: Scalars["String"];
};

export type SubscCommentSubscription = { __typename?: "Subscription" } & {
  commentAdded: Maybe<
    { __typename?: "Comment" } & Pick<Comment, "id" | "content">
  >;
};

オレオレプラグインを書く

graphql_codegenのプラグインを自分で書こう。returntsの文字列さえ吐ければ良い。

参考: Write your first Plugin · GraphQL Code Generator

プラグイン名.js
module.exports = {
  plugin: (schema, documents, config) => {
    //graphql_codegenがかき集めた、おおよそ人が読むようにできていない、documentsを読み込みながら、jsでts(文字列)を書く。
    //ここでtsをreturnする。
    //出力は勝手にgraphql_codegenがprettierが走らせて整形してくれるので、改行とかタブとか気にせず書く。
  }
};

自分が作ったプラグインの読み込み

codegen.yaml
overwrite: true
schema: "schema.graphql" # サーバのアドレス。勝手にスキーマを呼んでくれる
documents: "queries/*.graphql" # 監視対象 ここのファイルに更新があれば自動的に型を作成
generates: # 生成先
  ./generated/types.ts:
    plugins:
      - typescript
      - typescript-operations
  ./generated/nodes.ts: # 生成先
    plugins:
      - typescript-document-nodes
  ./generated/class.ts: # 生成先
      - プラグイン名.js # プラグイン読み込み

プラグインを書くにあたっては、キャメルケースなりを変換してくれるchange-caseパッケージが助かった。

結局何を書いたか?

結局こう言うコードを書いた。

type-apollo-class.js
var path = require("path");
var changeCase = require("change-case");

const imp = `
    import * as Type from "./types";
    import * as Node from "./nodes";
    import * as ApolloType from "apollo-client";
    import ApolloClient from "apollo-client";
    export interface ClientClass {readonly client: ApolloClient<any>;}        
`;

function makeClassHeader(basename) {
  return `
    export class ${changeCase.pascalCase(
      basename
    )}Client implements ClientClass{
        constructor(readonly client: ApolloClient<any>) {}          
    `;
}

function makeClassMethods(operations) {
  return operations.map(e => {
    const camelName = changeCase.camelCase(e.name);
    const pascalName = changeCase.pascalCase(e.name);
    const pascalOperation = changeCase.pascalCase(e.operation);

    const queryType = `Type.${pascalName + pascalOperation}`;
    const variableType = `Type.${pascalName + pascalOperation + "Variables"}`;
    const optionType = getOptionName(e.operation, queryType, variableType);

    return `
    ${camelName} = (options?:Omit<${optionType},"${operationQueryName[e.operation]}">) => 
        this.client.${operationName[e.operation]}<${queryType},${variableType}>
        ({...options,...{${operationQueryName[e.operation]}:Node.${pascalName}}})    
    `;
  });
}

const operationName = {
  query: "query",
  mutation: "mutate",
  subscription: "subscribe"
};

function getOptionName(operation, query, variable) {
  switch (operation) {
    case "query":
      return `ApolloType.QueryOptions<${variable}>`;
    case "mutation":
      return `ApolloType.MutationOptions<${query},${variable}>`;
    case "subscription":
      return `ApolloType.SubscriptionOptions<${variable}>`;
  }
}

const operationQueryName = {
    query: "query",
    mutation: "mutation",
    subscription: "query"
};

module.exports = {
  plugin: (schema, documents, config) => {
    const classes = documents
      .map(doc => {
        const filePath = doc.filePath;
        const baseName = path.basename(filePath, path.extname(filePath));

        const classHeader = makeClassHeader(baseName);

        const definitions = doc.content.definitions;
        const operations = definitions.map(e => ({
          operation: e.operation,
          name: e.name.value
        }));
        const methods = makeClassMethods(operations);

        return [classHeader, methods.join("\n"), `}`].join("\n");
      })
      .join("\n");

    return [imp, classes].join("\n");
  }
};

そして設定ファイルをこう書いた

codegen.yaml
overwrite: true
schema: "schema.graphql" # サーバのアドレス。勝手にスキーマを呼んでくれる
documents: "queries/*.graphql" # 監視対象 ここのファイルに更新があれば自動的に型を作成
generates:
  ./generated/types.ts:
    plugins:
      - typescript
      - typescript-operations
  ./generated/nodes.ts:
    plugins:
      - typescript-document-nodes
  ./generated/class.ts:
      - type-apollo-class.js

ごちゃっとしているけど、余は満足。

できた奴

まとめ

  • GraphQL楽しい
  • JSでTS(文字列)を書くと楽しい。
6
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?