JSON Schema をクックパッドマートの商品登録画面に導入した話

主にバックエンドのエンジニアとしてクックパッドマートの開発に携わっている塩出( @solt9029 )です。

美味しい食材をユーザにお届けするサービスであるクックパッドマートでは、日々街の販売店や地域の生産者が商品の登録を行っています。
商品を登録する際、販売者は消費期限をはじめとする様々な品質保証の情報を正確に入力する必要があります。
しかし、商品の種類や状態に応じて記載するべき品質保証の情報は異なるため、全項目が羅列されるフォームでは正確な入力が困難であり、販売者および商品の審査を行う社内の運用メンバに対して大きな負担をかけていました。 そこで、 JSON Schema を利用して複雑なフォームの出し分けを自動で制御し、またバックエンド側でのバリデーションも行うことが出来る仕組みを導入しました。
その結果、商品の種類や状態を選択するだけで、適切な品質保証の情報が自動的に入力され、必要な項目のフォームのみが表示されるようになり、販売者および商品の審査を行う社内の運用メンバの負担を大きく減らすことが出来ました。

f:id:solt9029:20210405174433g:plain:w250
JSON Schema を導入した商品登録画面

f:id:solt9029:20210406100722p:plain:w460
JSON Schema による商品の種類ごとのフォームの比較

背景・目的

クックパッドマートは、弊社が力を入れて取り組んでいる新規事業の1つです。生鮮食品を中心として扱っているECプラットフォームで、街の販売店や地域の生産者が、販売者としてクックパッドマートに参加しています。コンビニエンスストア・ドラッグストア・駅・マンションなどの様々な場所に、ユーザの受け取り場所として専用の冷蔵庫が設置されています。ユーザはアプリから注文を行い、冷蔵庫から生鮮食品を受け取ることができます。

クックパッドマートでは、販売者が商品の登録や日々の出荷作業などを行うための機能を提供する販売者向け管理画面を開発しています。
販売者向け管理画面を通じて商品登録をする際に、商品名や写真、価格だけでなく、消費期限や解凍品かどうかなどの、品質保証や食品表示に関わる情報も入力する必要があります。
一方で、それらの情報は商品の種類や状態に応じて入力するべきものが異なり、全項目が羅列されるフォームでは正しい項目を入力することが難しい状態でした。

例えば、じゃがいもをそのまま販売する場合、消費期限を入力する必要はなく、「お早めにお召し上がりください」といった文言を特記事項として記載する必要があります。
また、製品として販売されているドレッシングのように、商品自体に消費期限や賞味期限が元から記載されている場合、消費期限ではなく保証消費期限として、出荷日から最低限品質が保証される日数を入力する必要があります。この場合、販売者は保証消費期限よりも長い日数の消費期限や賞味期限が記載された商品を出荷する必要があります。
その他には、鮮魚や魚介加工品などの商品を販売するときには、生食用なのか・養殖なのか・解凍品なのか、といった項目を明示する必要があります。
このように、商品登録をする際には、商品の種類や状態に応じてそれぞれ異なった種類のデータを入力する必要がありますが、全項目が羅列されるフォームから人手でどの情報を入力するべきかを都度判断するのはとても困難です。そのため、商品の種類や状態に応じて、適切なフォームが出し分けされる仕組みが求められていました。

また、フロントエンド側でフォームの出し分け制御がされるだけでは、不正なデータの登録を完全に防ぐことはできません。社内の運用メンバが商品の販売開始前に商品審査を行っているものの、商品審査の負担やミスを避けるために、バックエンド側でバリデーションされた上で商品が登録されている状態が望ましいです。

複雑なフォームの出し分けのみであれば、 JavaScript でオレオレ実装をすることも考えましたが、バックエンド側のバリデーションまで考慮すると、共通した Schema が存在している状態が望ましいと考えました。そこで、複雑なフォームの出し分けおよびバリデーションをすることが可能な仕組みとして、 JSON Schema を導入することにしました。

実装

JSON Schema とは

JSON Schema とは、その名の通り JSON の構造を定義したものです。 OpenAPI で利用されている記述方法として知っている方も多いかもしれません。百聞は一見にしかずということで、JSON Schema とそれに対応する JSON の簡単なサンプルをご紹介します。

{
  title: "お料理レシピ",
  type: "object",
  properties: {
    id: { title: "ID", type: "integer" },
    title: { title: "タイトル", type: "string" },
    content: { title: "作り方", type: "string" },
    public: { title: "公開中", type: "boolean" }
  },
  required: ["id", "title", "content", "public"]
}
{
  id: 100,
  title: "カルボナーラの作り方",
  content: "ベーコンと玉ねぎを食べやすい大きさに切ります。〜(以下略)",
  public: true
}

このように型や必須項目など、 JSON の構造を定義することができます。他にも、本記事で扱う dependencies や oneOf などといった、複雑な構造を定義するときに便利な方法が豊富に用意されています。より詳細な仕様については、 Understanding JSON Schema をご参照ください。

JSON Schema の定義

商品登録時の JSON Schema を定義するにあたって、商品の種類ごとに必要なフォームの出し分けができるような構造を考える必要がありました。詳細な説明は省きますが、代表的な商品の種類を一部抜粋してご紹介します。

  • 根菜類(玉ねぎ、人参、じゃがいも)
    • 品質保証の種類:品質保証に関する特記事項 → テキストを入力するフォームが必要(デフォルトでは「お早めにお召し上がりください。」と入力される)
  • 鶏肉
    • 品質保証の種類:消費期限 → 日数を入力するフォームが必要
    • 解凍表示を入力するフォームが必要
  • 魚介加工品
    • 生食表示(生食用 / 加熱用)を入力するフォームが必要
    • 養殖表示(養殖 / 天然)を入力するフォームが必要
    • 解凍表示を入力するフォームが必要
    • 解凍品を選択した場合
      • 品質保証の種類:消費期限 → 日数を入力するフォームが必要
    • 非解凍品を選択した場合
      • 品質保証の種類:保証消費期限(配送日から最低限品質が保証される期間) → 日数を入力するフォームが必要

特に魚介加工品が一番複雑に見えると思います。このように、ある特定の値に応じてフォームの出し分けをする必要がある場合には、 JSON Schema の definitions・dependencies・oneOf などを利用します。魚介加工品の要件を JSON Schema として表現したときに、最終的には下記のようになりました。それなりに複雑な JSON にはなりますが、自前で出し分けを自動で制御するロジックやバリデーションをゼロから実装するよりも、遥かに簡単に記述することができました。

{
  required: ["raw", "thawed", "farmed"],
  properties: {
    category_id: { const: "魚介加工品カテゴリのID" },
    thawed: { "$ref" => "#/definitions/thawed" },
    farmed: { "$ref" => "#/definitions/farmed" },
    raw: { "$ref" => "#/definitions/raw" },
  },
  dependencies: {
    thawed: {
      oneOf: [
        {
          properties: {
            thawed: { const: true }, # 解凍品だった場合、消費期限
            quality_guarantee: { "$ref" => "#/definitions/quality_guarantee/definitions/expiration" },
          },
        },
        {
          properties: {
            thawed: { const: false }, # 非解凍品だった場合、保証消費期限
            quality_guarantee: { "$ref" => "#/definitions/quality_guarantee/definitions/guarantee_expiration" },
          },
        },
      ],
    },
  },
}

ライブラリ選定

クックパッドマートの販売者向け管理画面について、フロントエンドは Rails の View の仕組みを用いて HTML が返される仕組みとなっています。また、動的な処理などを追加する際には TypeScript / React を用いている状態です。そのため、 React で JSON Schema に基づいたフォームの出し分けを自動で制御するライブラリとして、 react-jsonschema-form を利用することとしました。
また、バックエンドについては Rails で開発が行われているため、 Ruby 製で JSON Schema に基づくバリデーションをすることができるライブラリが必要でした。そのため、 json_schemer を選定することにしました。

json_schemer

json_schemer はとても簡単に導入することができました。下記のように検証したい JSON を渡してあげることでバリデーションをすることができます。

JSONSchemer.schema(json_schema).valid?(json_to_be_validated)

react-jsonschema-form

react-jsonschema-form が JSON Schema の定義に沿ってフォームの出し分けを自動で制御してくれるため、自分で実装する必要のある箇所は主に見た目に関する部分でした。具体的には uiSchema と、 Widget や FieldTemplate と呼ばれる React Component です。

FieldTemplate や Widget は JSON Schema のそれぞれの入力フォームを描画する際に利用される Component です。JSON Schema および後述する uiSchema で渡される値を Props として受け取り、その情報を元に描画を行います。実装例は下記の通りです。

export const FieldTemplate = (props: FieldTemplateProps) => {
  const { label, required, children, rawDescription, rawHelp } = props;

  return (
    <Card>
      <Card.Header>
        <div>
          {required ? (
              <Badge variant="primary">必須</Badge>
          ) : (
              <Badge variant="secondary">任意</Badge>
          )}
          {label}
        </div>
      </Card.Header>
      <Card.Body>
        {rawDescription && <Card.Text>{rawDescription}</Card.Text>}
        {children} {/* この部分で Widget の描画が行われる */}
        {rawHelp && <small>{rawHelp}</small>}
      </Card.Body>
    </Card>
  );
};
export const RadioWidget = (props) => (
  <div className="field-radio-group">
    {props.options.enumOptions.map((option, i) => {
      return (
        <div key={i}>
          <label>
            <input
              disabled={props.disabled}
              type="radio"
              name={props.options.name}
              value={option.value}
              onChange={() => {
                props.onChange(option.value);
              }}
            />
            <span>{option.label}</span>
          </label>
        </div>
      );
    })}
  </div>
);

uiSchema について、下記は養殖か天然かを入力するフォームの定義例です。その入力フォームを描画するときに使用したい Widget の指定や、説明文の付与などの指定を行うことができます。uiSchema で指定された値は Widget の Props として渡されます。

{
  farmed: {
    'ui:disabled': isDisabled,
    'ui:name': 'item[farmed]',
    'ui:widget': RadioWidget,
    'ui:help': '養殖か天然を必ず選択してください。',
  },
  // ...
}

JSON Schema を導入した結果

これまでは新規登録された商品の内、約10%の割合で品質保証の項目の入力不備がありましたが、JSON Schema を導入したことによって、商品の種類や状態を選ぶだけで品質保証の種類が自動的に選択されるようになったため、品質保証の項目に関する入力不備はゼロになりました。商品登録時の正確性や体験を改善し、商品審査の運用負担を大きく減らすことができました。
Schema に基づいた実装を行っているため、今後新しく要件が増えたとしても JSON Schema の定義を更新するのみで解決し、フロントエンド側のフォームの出し分け制御ロジック・バックエンド側のバリデーションを容易に追加・更新することが可能な状態になりました。

最後に

クックパッドマートでは事業成長のためにスピードを高めて開発に取り組んでおり、様々な技術に触れる機会も多くとても楽しい環境です。弊社では絶賛エンジニア募集中なので、興味を持って頂けた方はぜひ採用情報をご覧ください。

cookpad-mart-careers.studio.site

info.cookpad.com

Cookpad Online Spring Internship 2021 と Hackarade を合同開催しました

ユーザー・決済基盤部の三吉(@sankichi92)です。 昨年よりエンジニアの立場から新卒採用を担当しています。

2月の記事で告知したスプリングインターンシップを 3/22〜26 の日程で開催しました。 また、同時に社内でも Hackrade Remote #2 を開催し、社員もインターン生と同じ課題に取り組みました。 この記事はその開催レポートです。

合同開催について

今回のインターンシップのコンセプトは「クックパッドの社内ハッカソン "Hackarade" を体験してみよう!」というものでした。 Hackarade は Hack + Parade を組み合わせた社内の造語で、ハッカソンではあるものの「競技」より「祭り」の側面が強い社内イベントです。 アイデアや成果を競い合うのではなく、社内エンジニアの技術力向上を目的としています。 過去の Hackarade については、"Hackarade" でブログ内を検索してみてください。

そして、せっかく Hackarade をやるのならインターン生だけではもったいない、ということで社内 Hackarade の合同開催が決まりました。 具体的には、インターンシップと同じ期間に社内でも Hackarade を開催し、成果発表をインターン生と合同で行うことにしました。 以下は、合同開催が決定した時のツイートです。

クックパッドでは昨年の春よりオンラインでインターンシップを開催していますが、オンラインでは社内の雰囲気が伝わりづらいという課題がありました。 合同開催には、実際の社内イベントと重ねて関わる社員を増やすことで、会社の様子を少しでもイメージしやすくする狙いもあります。 実際、インターン生がシングルチャンネルゲストとして参加する Slack チャンネルは、これまでのインターンシップと比べても非常に活発でした。 スクリーンショットのように、インターン生21人を含む83人から5日間で3,000件を超えるメッセージが投稿されました1f:id:sankichi92:20210401000058p:plain

講義動画について

Hackarade には毎回テーマがあり、社内の第一人者による講義を受けたのち開発に取り組む形式が基本です。 今回のインターンシップ & Hackarade のテーマは「Web フロントエンド」で、講師を外村(@hokaccha)が務めました。 また、オンラインのメリットを活かす取り組みとして、講義動画を事前に撮影し、参加者各自の好きなタイミングで視聴してもらう形を取りました。 わからなかったところは何度も見返したり、知っている内容は倍速で飛ばしたりできると、インターン生・社員ともに好評でした。

ここで、実際に使用した講義動画を公開します。

講義動画はトピックごとに以下の4つからなります。

  1. JavaScript
  2. TypeScript
  3. React
  4. Next.js

モダン Web フロントエンドに入門したい方や、上に挙げたようなトピックをおさらいしたい方など、ぜひご覧になってください。

インターンシップについて

インターンシップは、初日に講義動画の共有や課題の発表を行い、最終日5日目に成果発表してもらう、というスケジュールでした。 その間、質問は Slack で受け付け、講師・TA の誰か回答できる人が回答します。 テキストだけで難しい場合は、Zoom を使用することもありました。

課題は、Next.js と TypeScript を使ってレシピサイトを作る、というものでした。 基本課題と発展課題に分かれており、基本課題は講義動画を見ればだいたいできる内容です。 一方、発展課題は Web フロントエンドに慣れている人でも5日間やることがなくならないよう、かなりのボリュームになっています。 詳細は https://gist.github.com/hokaccha/7003c700f7d2ad276bfb458edd862abe をご覧ください。 データについて、今回はインターンシップ用に Web API を用意しました。

講師の想定を超えて、基本課題を2日目の朝6時に終える猛者や、すべての発展課題に取り組む猛者がいました。 同じ課題に取り組んでいても、成果発表では参加者ごとの個性がよく出ていて感心しました。

今回のインターンシップでの大きなチャレンジは、従来のような対面型でのインターンを単にオンラインにするのではなく、オンラインならではの効果が得られるような実施方法を模索することでした。 先ほど触れた講義動画はそのひとつです。 会場という制約がないので、必ず全員が参加しなければならない場面も減らすことができます。 5日間を通して、時間を合わせて集まるのは初日と最終日のみの計5時間ほどでした。 2〜4日目は、毎日1時間オフィスアワーとして講師・TAが Zoom に待機する時間を設けていましたが、そちらよりむしろテキストのコミュニケーション方が活発でした。 3日目には、Tech MTG という隔週で開催しているエンジニア全員参加のミーティングを見学する機会も設けましたが、これも任意参加です。 事前に2〜4日目の時間の使い方は自由とアナウンスしていたので、インターンシップ期間中に研究したり、卒業式に出席したり、引越ししたりする参加者がいました。 インターンシップの様子は、Twitter ハッシュタグ #cookpad_spring_intern から覗くことができます。

Hackarade について

社内の Hackarade は、前回のリモート開催時同様、開催期間のうち「8時間まで開発に使ってよい」というルールにしました。 また、インターンシップと異なり、以下の2部門があります。

  • 規定部門: インターン生向け課題のレギューレーションに従って開発する
  • 自由部門: 講義内容の技術を使って自由に開発する

規定部門はインターン生が5日間かけて取り組むボリュームということもあり、自由部門のエントリーが多くなりました。 ここからはいくつか作品をピックアップして紹介します。

Ruby コミッターの @mametter からは「ブラウザの上で Ruby を動かす」という発表がありました。 Web フロントエンドがテーマのはずなのに、irb が(ブラウザで)動き始めるという、予想の斜め上をいく作品でした。

また、おまけとして1時間(!)で書いた Next.js を使った Quine の紹介もありました。 遠藤さんの Quine は社内で有名ですが、インターン生にはどう見えたのか気になるところです。

買物プロダクト開発部の @solt9029 からは「ズルできるババ抜き」の発表がありました。 発表中は「これを作ろうと思った思考プロセスが気になる」といったコメントが寄せられました。

他にも、分析 SQL のシェアができるアプリや、GHE・Slack などから社内情報をまとめて検索できるアプリなど、社員待望の便利ツールや、業務での利用を視野に入れた QR コード読み取り機能のプロトタイプ、趣味でやっているポッドキャストの Web ページなどなど、多彩な作品が集まりました。

また、規定部門の発表では、講師・TA から Auth0 を使った認証やインクリメンタルサーチ、Storybook を使ったコンポーネント開発、Recoil を使った状態管理など、課題では扱われなかった少し発展的な技術の紹介がありました。


以上が、Cookpad Online Spring Internship 2021 & Hackarade Remote #2 の開催報告です。 ご参加いただいた皆さま、本当にありがとうございました!

スプリングインターンシップは終わってしまいましたが、クックパッドでは就業型インターンシップを通年で募集しています。

また、サマーインターンシップも例年どおり開催予定です。 興味のある方はぜひご応募ください!


  1. アプリやインテグレーションによる投稿はなく、すべて人間による投稿です。Tech MTG や成果発表では多くの社員を巻き込んで盛り上がりました。

GraphQL Code Generator で TypeScript の型を自動生成する

技術部の外村(@hokaccha)です。

レシピサービスのフロントエンドを Next.js と GraphQL のシステムに置き換えている話 - クックパッド開発者ブログ

という記事を書きましたが、この中で詳しく説明しなかった GraphQL のスキーマやクエリから TypeScript の型定義を自動生成する仕組みについて紹介します。

なお、今回紹介したコードは以下で試せます。

https://github.com/hokaccha/graphql-codegen-example-for-techlife

GraphQL Code Generator を使った型生成

GraphQL のスキーマから TypeScript の型を生成するためのライブラリはいくつかあります。

などが有名どころです。今回はシンプルさや拡張性を考えて GraphQL Code Generator を採用したので、GraphQL Code Generator を使ったコード生成について紹介します。

GraphQL Code Generator 自身は TypeScript 以外の言語に対応していたり、TypeScript の中でも様々な機能のプラグインが提供されており、その中から自分の用途にあったプラグインを組み合わせを選ぶことになります。

今回は自動生成で以下の目的を達成します。

  • クライアントサイドで発行するリクエストとレスポンスに TypeScript の型をつける
  • サーバーサイドの Resolver に TypeScript の型をつける

クライアントサイドとサーバーサイドで利用するプラグインや実装は分断されるのでそれぞれ分けて解説します。

クライアントサイド

まずはクライアントサイドです。使っているプラグインは以下です。

@graphql-codegen/typescript は TypeScript の型生成する場合に必用なプラグインで TypeScript のコードを生成する場合に必用です。@graphql-codegen/typescript-operations は GraphQL のクエリとスキーマを元に TypeScript の型を自動生成します。

設定ファイルは次のようになります。

# codegen.yml
schema: schema.graphql
documents: graphql/**/*.graphql
generates:
  lib/generated/client.ts:
    plugins:
      - typescript
      - typescript-operations

documents には、リクエストするときに発行するクエリを記述したファイルを指定します。スキーマとクエリは次のようになっていたとします。

# schema.graphql
type Recipe {
  id: Int!
  title: String!
  imageUrl: String
}

type Query {
  recipe(id: Int!): Recipe!
}
# graphql/getRecipe.graphql
query getRecipe($id: Int!) {
  recipe(id: $id) {
    id
    title
    imageUrl
  }
}

getRecipe.graphql はアプリケーションから発行するクエリです。アプリケーションのコード内に書くのでなく、ファイルを分けて書いています。

これで graphql-codegen コマンドを実行すると以下のコードが生成されます。

export type Maybe<T> = T | null;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
  ID: string;
  String: string;
  Boolean: boolean;
  Int: number;
  Float: number;
};

export type Recipe = {
  __typename?: 'Recipe';
  id: Scalars['Int'];
  title: Scalars['String'];
  imageUrl?: Maybe<Scalars['String']>;
};

export type Query = {
  __typename?: 'Query';
  recipe: Recipe;
};


export type QueryRecipeArgs = {
  id: Scalars['Int'];
};

export type GetRecipeQueryVariables = Exact<{
  id: Scalars['Int'];
}>;


export type GetRecipeQuery = (
  { __typename?: 'Query' }
  & { recipe: (
    { __typename?: 'Recipe' }
    & Pick<Recipe, 'id' | 'title' | 'imageUrl'>
  ) }
);

このとき、スキーマとクエリに型の不整合があればコード生成のときにエラーになるので、スキーマに違反するようなクエリを書くことができません。例えばクエリを以下のようにしてみます。

query getRecipe($id: Int!) {
  recipe(id: $id) {
    foo
  }
}

これで graphql-codegen を実行するとこのようにエラーになります。

$ npx graphql-codegen
  ✔ Parse configuration
  ❯ Generate outputs
    ❯ Generate lib/generated/client.ts
      ✔ Load GraphQL schemas
      ✔ Load GraphQL documents
      ✖ Generate
        →         at ~/local/src/github.com/hokaccha/graphql-codegen-example-for-techlife/client/graphql/getRecipe.graphql:3:5


 Found 1 error

  ✖ lib/generated/client.ts
    AggregateError:
        GraphQLDocumentError: Cannot query field "foo" on type "Recipe".
    (snip)

これだけでもだいぶ便利ですね。

しかし、まだこれだけだと型が生成されただけでアプリケーション内でリクエストとレスポンスに型を与えることはできていません。それを実現するのが @graphql-codegen/typescript-graphql-request です。これは graphql-request というライブラリをベースにしたクライントを自動で生成してくれます。

他にも react-query をベースにして React hooks として使える @graphql-codegen/typescript-react-query など、いくつか選択肢はありますが、今回は SSR でも同じ用に使えてできるだけシンプルなものという理由で @graphql-codegen/typescript-graphql-request を選択しました。

設定ファイルは pluginstypescript-graphql-request を足すだけです。

# codegen.yml
schema: schema.graphql
documents: graphql/**/*.graphql
generates:
  lib/generated/client.ts:
    plugins:
      - typescript
      - typescript-operations
      - typescript-graphql-request

これでもう一度 graphql-codegen を実行すると、先程の生成したファイルに追加して以下のようなコードが生成されます。

export const GetRecipeDocument = gql`
    query getRecipe($id: Int!) {
  recipe(id: $id) {
    id
    title
    imageUrl
  }
}
    `;

export type SdkFunctionWrapper = <T>(action: () => Promise<T>) => Promise<T>;


const defaultWrapper: SdkFunctionWrapper = sdkFunction => sdkFunction();
export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = defaultWrapper) {
  return {
    getRecipe(variables: GetRecipeQueryVariables, requestHeaders?: Dom.RequestInit["headers"]): Promise<GetRecipeQuery> {
      return withWrapper(() => client.request<GetRecipeQuery>(GetRecipeDocument, variables, requestHeaders));
    }
  };
}
export type Sdk = ReturnType<typeof getSdk>;

先程別ファイルにした getRecipe クエリもこの自動生成コードに含まれており、getRecipe() でこのクエリを使ってリクエストし、レスポンスにも型がつきます。アプリケーションからはこのように使います。

import { GraphQLClient } from "graphql-request";
import { useEffect, useState } from "react";
import { getSdk, Recipe } from "./generated/client";

const client = new GraphQLClient("http://localhost:8000/graphql");
const sdk = getSdk(client);

async function getRecipe(id: number) {
  const response = await sdk.getRecipe({ id });
  // const response = await sdk.getRecipe(); // Error: Expected 1-2 arguments, but got 0.

  console.log(response.recipe.id); // id: number
  console.log(response.recipe.title); // title: string
  console.log(response.recipe.imageUrl); // imageUrl?: string

  // @ts-expect-error
  console.log(response.recipe.foo); // Property 'foo' does not exist

  return response.recipe;
}

このように、リクエストとレスポンスに対して自動生成された型がつきます。また、クエリの入力部分は query getRecipe($id: Int!) でしたが、この入力パラメータについても型がつきます。

これでクライアントにおけるリクエストとレスポンスに型がつきました。

サーバーサイド

次にサーバーサイドです。サーバーサイドでは以下のプラグインを使います。

@graphql-codegen/typescript-resolvers が GraphQL サーバーの Resolver の型を自動生成するためのプラグインです。

設定ファイルは次のようにします。

# codegen.yml
schema: schema.graphql
generates:
  lib/generated/resolvers.ts:
    plugins:
      - typescript
      - typescript-resolvers

これで生成された型定義を使って Resolver を次のように書きます。

import { Resolvers } from "./generated/resolvers";

export const resolvers: Resolvers = {
  Query: {
    recipe: async (_parent, args, _context, _info) => {
      return {
        id: args.id,
        title: "recipe title",
        imageUrl: null,
      };
    },
  },
};

これだけです。args に入力値が渡ってきますが、これにはスキーマで指定されている id: Int! の型が渡ってきます。もちろん返り値もチェックされているので返している値がスキーマと整合性が取れていないと型エラーになります。

この Resolver は、graphql-toolsmakeExecutableSchema にそのまま渡せる型として定義されます。ですので、makeExecutableSchema で作った schema をそのまま実行できる Apollo や graphql-express などで実行します。以下は graphql-express の例です。

import fs from "fs";
import express from "express";
import cors from "cors";
import { makeExecutableSchema } from "graphql-tools";
import { graphqlHTTP } from "express-graphql";
import { resolvers } from "./lib/resolvers";

const typeDefs = fs.readFileSync("./schema.graphql", { encoding: "utf8" });

const schema = makeExecutableSchema({
  typeDefs,
  resolvers,
});

const app = express();

app.use(cors());
app.use("/graphql", graphqlHTTP({ schema }));

app.listen(8000, () => {
  console.log("listen: http://localhost:8000");
});

これでサーバーサイドにも型がつきました。

まとめ

GraphQL のスキーマやクエリから TypeScript の型定義やクライアントを自動生成する方法について紹介しました。実際にクックパッドのアプリケーションでもほぼ同じ仕組みで動いています。できるだけシンプルに寄せるため Apollo などは使っておらず必要最低限にしていますが、GraphQL や TypeScript の強力な型付けの恩恵を受けることができて非常に便利です。

また、現状では Apollo や React Query などは組み合わせて使っておらず、キャッシュや hooks 化などは必要に応じてやっていますが、GraphQL Code Generator はそのあたりのプラグインも豊富で変更したいとなったときにプラグインの追加で気軽に構成変更できるのも便利です。

まだまだこのあたりも環境整備も発展途上ですので我こそは最高の環境を作るぞという方、もしくは API 呼び出しに型がなくて疲れてしまい、このような環境で開発してみたくなった方はお気軽にお問い合わせください!

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/