Open118

Apollo OdysseyでGraphQL(のApolloによる実装)を完全に理解したい

qmotasqmotas

概要

GraphQLを体系的に習得するのによいドキュメント・チュートリアルはないかと探したところ、Apolloが公式で提供しているApollo Odysseyがよさそうだったので基礎部分をやってみる

全体的に説明 -> 手を動かすという構成になっているので、このスクラップでは分かりやすいよう見出しに絵文字をつける

  • 📖 説明
  • ✍️ 手を動かすもの

https://www.apollographql.com/tutorials/

qmotasqmotas

このログをチームで共有してやってもらうことも考えているので、チュートリアルの日本語訳を箇条書きにしながら手を動かしていく

内容は省略や意訳、個人的な意見を書くことがあるので参照する際は注意されたい

qmotasqmotas

Odysseyのサイトでサインアップすると進捗が記録される

Node.jsのバージョンが16前提のようなので、17以上を使っているとところどころエラーが発生する可能性がある

qmotasqmotas
qmotasqmotas

全体的に、実開発ではTypeScriptを使ってESModule形式でモジュールを参照することを想定しているため、import/exportで書き直している

チュートリアルが進むと予め用意されているコードもimport/exportに書き換えないと動かないため、面倒ならCommonJS形式で進めるのがよい

qmotasqmotas

開始時点のスキル

  • GraphQLについてはなんとなくの知識がある程度で、クライアントもサーバも実装したことはない
  • なんとなくの理解
    • 単一のエンドポイントにクエリを投げて指定した形式でレスポンスを返してくれる
    • スキーマ駆動で、コード生成によって様々な言語で型安全にクライアントを実装できる
    • スキーマはグラフ構造で、1回のクエリで入れ子のデータをガッと取得できる
      • GraphQL Federationで複数のサービス(エンドポイント)を集約できる
        • GraphQLのマイクロサービスに対するBFF
  • TypeScript、Next.js、React.js、Recoil.jsでアプリをスクラッチ開発したことがある
    • バックエンドはNext.jsのAPI Routesでzodスキーマ/型定義をフロントエンドと共有
qmotasqmotas

📖 Apollo Odysseyとは

https://www.apollographql.com/tutorials/のWelcome to Apollo Odysseyを翻訳

OdysseyはApolloの公式学習プラットフォームで、無料のハンズオンGraphQLチュートリアルを提供しています。GraphQLの旅を始めるには最適な場所です。Odysseyのコースは、自分のスケジュールに合わせて完了できる、短くて簡潔なレッスンの集合体です。各レッスンには、ビデオとそれに対応する文章が付属しています。さらに、私たちのGraphQLチュートリアルは、コードチャレンジ、タスク、クイッククイズで充実しており、インタラクティブな体験を提供し、知識を強化することができます。

qmotasqmotas

Lift-off I: Basics - チュートリアルの概要と準備 ✍️ 📖

https://www.apollographql.com/tutorials/lift-off-part1/feature-overview-and-setup

📖 概要

  • 題材として、CatstronautsというフルスタックのGraphQLアプリを作っていく
    • 宇宙飛行士の猫向けの学習プラットフォーム
  • まずはモックデータを返すGraphQL APIを実装してスキーマの定義を学ぶ
  • スキーマファーストな開発の典型的な流れ
    1. スキーマを定義する
    • 機能が必要とするデータを特定し、データをできるだけ直感的に提供できるようスキーマを構造化する
    1. バックエンド実装
    • Apollo ServerでGraphQL APIを実装し、求められたデータをあらゆるデータソースからフェッチする(ここではモックデータを使う)
    • 後続のコースではREST APIに接続する
    1. フロントエンド実装
    • GraphQL APIから取得したデータをビューに描画する
  • スキーマファーストな設計の利点として、フロントエンドとバックエンドの開発を並行することで開発に要する時間を削減できるというのがある
    • フロントエンドチームはスキーマさえあればモックデータで開発をスタートできる
    • バックエンドチームも同時にスキーマに基づいてAPIを実装できる
    • これがGraphQL API設計の唯一の手段ではないが、(チュートリアル提供元のApolloは)効果的であると確信している
qmotasqmotas

チュートリアルの前提条件

  • バックエンドはNode.js
  • フロントエンドはReact.js
  • importmapasyncjsxといったキーワードやそのコンセプト、React Hooksに慣れていること
qmotasqmotas

✍️ 準備

✍️ リポジトリをクローンする

git clone https://github.com/apollographql/odyssey-lift-off-part1

プロジェクトの構成

  • server/がバックエンド
  • client/がフロントエンド
  • final/はチュートリアルの完成形なので、必要に応じて参考にするとよい
📦 odyssey-lift-off-part1
 ┣ 📂 client
 ┃ ┣ 📂 public
 ┃ ┣ 📂 src
 ┃ ┣ 📄 README.md
 ┃ ┣ 📄 package.json
 ┣ 📂 server
 ┃ ┣ 📂 src
 ┃ ┃ ┣ 📄 index.js
 ┃ ┣ 📄 README.md
 ┃ ┣ 📄 package.json
 ┣ 📂 final
 ┃ ┣ 📂 client
 ┃ ┣ 📂 server
 ┗ 📄 README.md

✍️ バックエンドアプリの起動

server/へ移動して

npm install && npm start

✍️ フロントエンドアプリの起動

client/へ移動して

npm install && npm start
qmotasqmotas

Chromeが自動で起動して空のアプリが表示されればOK

qmotasqmotas

Lift-off I: Basics - データの要件 📖

https://www.apollographql.com/tutorials/lift-off-part1/feature-data-requirements

デザインのモック

https://www.apollographql.com/tutorials/lift-off-part1/feature-data-requirements

講習のトラックごとカード表示したい

トラックカード単位のデータ構造

  • タイトル
  • サムネイル
  • トラックの時間(動画の長さとか、受講時間とかのイメージだと思う)
  • モジュール数(なんだろう、ここのコメントを後で補完する)
  • 作者名
  • 作者画像
qmotasqmotas

📖 グラフ

  • 前述のデータ構造をオブジェクトの集合として考える
  • 各オブジェクトをノード、ノード間の関連をエッジと呼ぶ
  • ノードとエッジで構成されたものをグラフと呼ぶ
    • -> グラフ理論
  • GraphQLではデータ構造をグラフで表現する
    • クエリも型として定義するので、すべてがグラフになる

トラックのデータ構造をグラフで描いた図

https://www.apollographql.com/tutorials/lift-off-part1/feature-data-requirements

qmotasqmotas

Lift-off I: Basics - スキーマ定義言語(SDL) 📖

https://www.apollographql.com/tutorials/lift-off-part1/schema-definition-language-sdl

  • スキーマはサーバとクライアント間の契約書のようなもの
    • APIが何をできて何をできないかということを定義する
  • スキーマはSDLと呼ばれる専用の言語で定義する
    • Schema Definition Language
type SpaceCat {
  name: String!
  age: Int
  missions: [Mission]
}

のように書く

qmotasqmotas

📖 型定義

  • JavaScriptのオブジェクトリテラルやTypeScriptの型エイリアスに似ているが、行末のセミコロンやカンマはない
  • 型は後置で、StringIntのようなプリミティブな型はスカラー型という
  • non-nullなフィールドはString!のように型名に!をつける
  • リストは[String]のように書く
    • リストそのものも要素もnon-nullなら[String!]!
qmotasqmotas

📖 description

"""graphql
オブジェクトの説明など
ブロック記法
"""
type SpaceCat {
  "フィールドの説明など"
  name: String!
  # これはコメント
  age: Int
}
  • オブジェクトやフィールドの説明を書くことができる
  • コメントとは異なる
    • descriptionはスキーマから生成されたコードやドキュメントに出力される
qmotasqmotas

Lift-off I: Basics - スキーマの実装 ✍️

https://www.apollographql.com/tutorials/lift-off-part1/building-our-schema

✍️ スキーマの実装

  • バックエンド側でスキーマを定義する
  • スキーマはJavaScriptでタグ付きテンプレートリテラルを用いて埋め込む

server/ディレクトリで依存パッケージをインストール

npm install apollo-server graphql

server/src/schema.jsを追加して、スキーマ定義用の関数をimportする

server/src/schema.js
import { gql } from 'apollo-server';
qmotasqmotas

https://zenn.dev/link/comments/37d4d5d91ed2e2 のデータ構造を型定義に落とし込んでいく

Trackの型定義

"A track is a group of Modules that teaches about a specific topic"
type Track {
  id: ID!
  title: String!
  author: Author!
  thumbnail: String
  length: Int
  modulesCount: Int
}

authorAuthor型(次に定義する)

Authorの型定義

"Author of a complete Track or a Module"
type Author {
  id: ID!
  name: String!
  photo: String
}
qmotasqmotas

Queryの型定義

データ構造に対するクエリも型として定義する

モック画面に表示するTrackの一覧を取得するクエリは例えば以下のように定義される

type Query {
  "Get tracks array for homepage grid"
  tracksForHome: [Track!]!
}
qmotasqmotas

✍️ schema.jsの実装

以上を踏まえて、schema.jsを以下のように実装する

server/src/schema.js
import { gql } from 'apollo-server';

const typeDefs = gql`
  type Query {
    "Get tracks array for homepage grid"
    tracksForHome: [Track!]!
  }

  "A track is a group of Modules that teaches about a specific topic"
  type Track {
    id: ID!
    "The track's title"
    title: String!
    "The track's main author"
    author: Author!
    "The track's main illustration to display in track card or track page detail"
    thumbnail: String
    "The track's approximate length to complete, in minutes"
    length: Int
    "The number of modules this track contains"
    modulesCount: Int
  }

  "Author of a complete Track"
  type Author {
    id: ID!
    "Author's first and last name"
    name: String!
    "Author's profile picture url"
    photo: String
  }
`;

export { typeDefs };
qmotasqmotas
qmotasqmotas

✍️ バックエンドアプリの実装

apollo-serverで、GraphQLサーバを動かす
以下のような機能をもつ

  • 単一のAPIエンドポイントをもつHTTPサーバとして動作し、GraphQLクエリを受け付ける
  • クエリのバリデーションを行う
  • スキーマに基づいてモックデータを返す(デフォルトでそれっぽい値を生成してくれる)

server/src/index.jsの中身を実装する

server/src/index.js
import { ApolloServer } from 'apollo-server';
import { typeDefs } from './schema.js';

const server = new ApolloServer({typeDefs});

server.listen().then(() => {
  console.log(`
    🚀  Server is running!
    🔉  Listening on port 4000
    📭  Query at http://localhost:4000
  `);
});

server/ディレクトリで以下コマンドを実行して起動してみる

npm start

> catstronauts-server-complete@1.0.0 start
> nodemon src/index

[nodemon] 2.0.6
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node src/index.js`

    🚀  Server is running!
    🔉  Listening on port 4000
    📭  Query at http://localhost:4000
qmotasqmotas

Lift-off I: Basics - Apollo Explorer ✍️

https://www.apollographql.com/tutorials/lift-off-part1/apollo-explorer

  • Apollo Explorerというツールでクエリの作成や実行が行える
  • Apollo StudioのApollo Sandboxで動く
    • 要アカウント作成
  • サーバを起動し、ブラウザで http://localhost:4000 を開くと下のようなページが表示されるのでQuery your serverボタンを押すとApollo Studioが開く

qmotasqmotas
  • 左ペインでクエリに含めたいフィールドを選択すると中央ペインにクエリが生成される
  • 中央ペイン上の青いボタンでクエリを実行
  • 右ペインにレスポンスが表示される

キャプチャはLift-off IIで実装したときのもの

qmotasqmotas

Lift-off I: Basics - フロントエンド ✍️

https://www.apollographql.com/tutorials/lift-off-part1/the-frontend-app

client/のディレクトリ構造

📂 client
  ┣ 📂 src
  ┃ ┣ 📂 assets
  ┃ ┣ 📂 components
  ┃ ┣ 📂 containers
  ┃ ┣ 📂 pages
  ┃ ┣ ...
  ┣ ...
  • pages/containers/が主な実装範囲
  • components/にはカード表示のコンポーネントなど、チュートリアルで使うコンポーネントが実装済みなので今回は特に触れない
  • Apollo Clientを使ってGraphQLのクエリを投げていく
qmotasqmotas

client/に移動してnpm startすると、http://localhost:3000 で開ける

ソースを変更して保存すると自動で画面が更新され、変更が反映される

qmotasqmotas

Lift-off I: Basics - Apollo Clientのセットアップ ✍️

https://www.apollographql.com/tutorials/lift-off-part1/apollo-client-setup

  • grqphqlパッケージと@apollo/clientパッケージを使う
qmotasqmotas

client/ディレクトリへ移動して必要なパッケージをインストールする

npm install graphql @apollo/client

client/src/index.jsでクライアントのパッケージをimportして実装していく

client/src/index.js
import { ApolloProvider, ApolloClient, InMemoryCache } from '@apollo/client';
qmotasqmotas
  • クライアントはApolloClientクラスのコンストラクタにオプションを渡して作成する
    • uriはローカルで起動したサーバのhttp://localhost:4000
    • cacheにはInMemoryCacheのインスタンス
client/src/index.js
const client = new ApolloClient({
  uri: 'http://localhost:4000',
  cache: new InMemoryCache(),
});
qmotasqmotas

Apollo Clientの機能をUIコンポーネントから使う(Context API)ため、<ApolloProvider>でトップレベルのコンポーネントをラップする

client/src/index.js
ReactDOM.render(
  <ApolloProvider client={client}>
    <GlobalStyles />
    <Pages />
  </ApolloProvider>,
  document.getElementById('root')
);

こうすることで、配下の任意のコンポーネントからApollo Clientのカスタムフック経由で必要なデータにアクセスできるようになる

qmotasqmotas

最終形

client/src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import GlobalStyles from './styles';
import Pages from './pages';
import { ApolloProvider, ApolloClient, InMemoryCache } from '@apollo/client';

const client = new ApolloClient({
  uri: 'http://localhost:4000',
  cache: new InMemoryCache(),
});

ReactDOM.render(
  <ApolloProvider client={client}>
    <GlobalStyles />
    <Pages />
  </ApolloProvider>,
  document.getElementById('root')
);
qmotasqmotas

Lift-off I: Basics - クエリを定義する ✍️

https://www.apollographql.com/tutorials/lift-off-part1/defining-a-query

  • クライアントの用意ができたので、実際に投げるクエリを書いていく
  • クエリはgql()関数にタグ付きテンプレートリテラルで渡す
  • クエリの変数名はUPPER_SNAKE_CASEにする
const TRACKS = gql`
  # ここにGraphQLクエリを書く
`
qmotasqmotas

TRACKSの実装

const TRACKS = gql`
  query getTracks {
    tracksForHome {
      id
      title
      thumbnail
      length
      modulesCount
      author {
        name
        photo
      }
    }
  }
`;
qmotasqmotas

Lift-off I: Basics - useQueryフック ✍️

https://www.apollographql.com/tutorials/lift-off-part1/the-usequery-hook

Reactアプリケーションからgql()関数で定義したクエリを実行して結果を得るにはuseQueryフックを利用する

client/src/pages/tracks.js
import { useQuery, gql } from '@apollo/client';

const TRACKS = gql`
  // ...
`;

const Tracks = () => {
  const { loading, error, data } = useQuery(TRACKS);

  // ...
}
  • クエリの実行は非同期だけど同期処理でラップしている
    • Recoil.jsのloadableと同じパターン
  • クエリの実行中はloadingtrueになるので、これを条件にして読込中の表示にフォールバックする
  • クエリの実行が完了するとdataに結果のオブジェクトが入る
    • エラーがある場合はerrorに内容が入る
qmotasqmotas

✍️ トラックの一覧を表示する<Track>コンポーネントを実装する

まずは素朴に実装してみる

  • 読込中ならメッセージを表示
  • エラーがあったらエラーメッセージを表示
  • 正常に結果が取得できたら内容をJSON文字列として表示
client/src/pages/tracks.js
const Tracks = () => {
  const {loading, error, data} = useQuery(TRACKS);

  if (loading) return 'Loading...';

  if (error) return `Error! ${error.message}`;

  return <Layout grid>{JSON.stringify(data)}</Layout>;
};

http://localhost:4000 にアクセスすると一瞬Loading...が表示されたあとJSON文字列がずらっと表示される

qmotasqmotas

✍️ トラックの内容を表示する<TrackCard>コンポーネントを使う

チュートリアルのプロジェクトにはclient/src/containers/track-card.jsとしてトラック表示用のコンポーネントが用意されているので、これを使って取得したトラックの内容を表示する

client/src/pages/tracks.js
const Tracks = () => {
  const {loading, error, data} = useQuery(TRACKS);

  if (loading) return 'Loading...';

  if (error) return `Error! ${error.message}`;

- return <Layout grid>{JSON.stringify(data)}</Layout>;
+ return (
+   <Layout grid>
+    {data?.tracksForHome?.map(track => (
+       <TrackCard key={track.id} track={track} />
+     ))}
+   </Layout>
+ );
};

これでこんなふうに表示されるようになる

qmotasqmotas

✍️ 一覧表示をラップする

  • このままだと画面表示時に一瞬Loading...の文字が表示される
  • クエリ実行の結果をラップする共通のコンポーネントを用意することで、実行するクエリに依らず読み込み中やエラーの表示を統一したい
  • チュートリアルのプロジェクトではclient/src/components/query-result.jsとしてすでに用意してあるのでこれを使う

<QueryResult>の実装はこんな感じ

const QueryResult = ({loading, error, data, children}) => {
  if (error) {
    return <p>ERROR: {error.message}</p>;
  }
  if (loading) {
    return (
      <SpinnerContainer>
        <LoadingSpinner data-testid="spinner" size="large" theme="grayscale" />
      </SpinnerContainer>
    );
  }
  if (!data) {
    return <p>Nothing to show...</p>;
  }
  if (data) {
    return children;
  }
};
qmotasqmotas

client/src/pages/tracks.jsを書き換える

client/src/pages/tracks.js
const Tracks = () => {
  const { loading, error, data } = useQuery(TRACKS);

- if (loading) return 'Loading...';
-
- if (error) return `Error! ${error.message}`;
-
  return (
    <Layout grid>
+     <QueryResult error={error} loading={loading} data={data}>
        {data?.tracksForHome?.map((track) => (
          <TrackCard key={track.id} track={track} />
        ))}
+     </QueryResult>
    </Layout>
  );
};

一覧表示前にスピナーが表示されるようになった

qmotasqmotas

tracks.jsの最終形

client/src/pages/tracks.js
import { gql, useQuery } from '@apollo/client';
import React from 'react';
import { Layout, QueryResult } from '../components';
import TrackCard from '../containers/track-card';

const TRACKS = gql`
  query TracksForHome {
    tracksForHome {
      id
      length
      modulesCount
      thumbnail
      title
      author {
        name
        id
        photo
      }
    }
  }
`;

/**
 * クエリ(TRACKS)の実行結果をuseQueryで取得して一覧表示する画面
 */
const Tracks = () => {
  const { loading, error, data } = useQuery(TRACKS);

  return (
    <Layout grid>
      <QueryResult error={error} loading={loading} data={data}>
        {data?.tracksForHome?.map((track) => (
          <TrackCard key={track.id} track={track} />
        ))}
      </QueryResult>
    </Layout>
  );
};

export default Tracks;
qmotasqmotas

Lift-off II: Resolvers - GraphQLクエリの道のり ✍️ 📖

https://www.apollographql.com/tutorials/lift-off-part2/journey-of-a-graphql-query

  • Lift-off IIではGraphQLのリゾルバについて学習する
  • Lift-off Iで作った一覧画面の中身を実装する
qmotasqmotas

✍️ 前準備

チュートリアルのリポジトリをクローンし、クライアントとサーバの起動を確認する
やることはLift-off Iと同じ

git clone https://github.com/apollographql/odyssey-lift-off-part2

server/へ移動して

npm install && npm start

client/へ移動して

npm install && npm start
qmotasqmotas

📖 GraphQLクエリの道のり

  • クライアント
    • HTTPリクエストでクエリを送信する
      • POSTまたはGET
  • サーバ
    • スキーマに基づいてクエリのパースとバリデーションを行う
    • クエリはAST(Abstract Syntax Tree: 抽象構文木)に変換される
    • ASTを走査(walk)しながら、各フィールドに対してリゾルバを実行し、結果をマップする
      • リゾルバは指定されたフィールドの値を取得して戻す関数
      • ASTの各ノードを対応したリゾルバで変換した写像を結果とする、と言える
    • HTTPレスポンスボディにdataをキーとして結果を格納して返す
  • クライアント
    • クエリの結果を使ってレンダリング
qmotasqmotas

Lift-off II: Resolvers - Exploring our data 📖

https://www.apollographql.com/tutorials/lift-off-part2/exploring-our-data

  • リゾルバがデータを収集する元ネタをデータソースと呼ぶ
    • DB、サードパーティAPI、ウェブフック、その他もろもろ
  • このチュートリアルではOdyssey用にホスティングされたREST APIをデータソースとする
qmotasqmotas

📖 データ構造について考える

REST APIのエンドポイント

GET   /tracks
GET   /track/:id
PATCH /track/:id
GET   /track/:id/modules
GET   /author/:id
GET   /module/:id

/tracksのレスポンス

[
  {
    "id": "c_0",
    "thumbnail": "https://res.cloudinary.com/dety84pbu/image/upload/v1598465568/nebula_cat_djkt9r.jpg",
    "topic": "Cat-stronomy",
    "authorId": "cat-1",
    "title": "Cat-stronomy, an introduction",
    "description": "Curious to learn what Cat-stronomy is all about? Explore the planetary and celestial alignments and how they have affected our space missions.",
    "numberOfViews": 0,
    "createdAt": "2018-09-10T07:13:53.020Z",
    "length": 2377,
    "modulesCount": 10,
    "modules": ["l_0", "l_1", "l_2", "l_3", "l_4", "l_5", "l_6", "l_7", "l_8", "l_9"]
  },
  {...},
]

https://odyssey-lift-off-rest-api.herokuapp.com/tracks

スキーマはこうだった

type Track {
  id: ID!
  title: String!
  author: Author!
  thumbnail: String
  length: Int
  modulesCount: Int
}
  • id
  • thumbnail
  • title
  • modulesCount
  • length

は、REST APIのレスポンスからそのまま取得できそう

その他、topicdescription等不要なプロパティもあるがこれは無視すればヨシ

qmotasqmotas

authorに対応する詳細なデータ(nameなど)は含まれていないので、authorIdを使って/author/:idからデータを取得する

https://odyssey-lift-off-rest-api.herokuapp.com/author/cat-1

{
  "id": "cat-1",
  "name": "Henri, le Chat Noir",
  "photo": "https://images.unsplash.com/photo-1442291928580-fb5d0856a8f1?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=1080&fit=max&ixid=eyJhcHBfaWQiOjExNzA0OH0"
}

こういうデータが返ってくる

Authorのスキーマに必要な情報は揃っているので、これら2つのAPIをデータソースとすればよさそう

type Author {
  id: ID!
  name: String!
  photo: String
}
qmotasqmotas

Lift-off II: Resolvers - Apollo RESTDataSource 📖

https://www.apollographql.com/tutorials/lift-off-part2/apollo-restdatasource

データの場所と構造について理解したところで、リゾルバからのアクセスについて考える

qmotasqmotas
  • GraphQLサーバからREST APIへのアクセス方法は2通り
    • Fetch API等で直接APIを叩く
    • ApolloのDataSourceというヘルパーを使う
  • 直接APIを叩くケースを考える
fetch('apiUrl/tracks').then(function (response) {
  // do something with our tracks JSON
});
  • 例えば/tracksが100件のレコードを返してきた場合、/author/:idを更に100回叩く必要がある(N+1問題)
  • アプリの仕様を考えたとき、おそらくこのデータは頻繁に変更されるものではない
    • 数週間ごとに1トラックとか
  • つまり、REST APIの実行結果をキャッシュすることで不要なアクセスを削減できそう
  • GraphQLでは、1つのクエリを複数の異なるキャッシュポリシーをもつエンドポイントから、異なるフィールドや型で構成することが多い
    • キャッシュのやり方を考えないといけない

そこで、GraphQLでREST APIの呼び出しをいい感じでキャッシュ・重複排除してくれる仕組みとしてRESTDataSourceが用意されている

qmotasqmotas

Lift-off II: Resolvers - RESTDataSourceの実装 ✍️

https://www.apollographql.com/tutorials/lift-off-part2/implementing-our-restdatasource

apollo-datasource-restを使う

qmotasqmotas

apollo-datasource-restをインストールする

npm install apollo-datasource-rest

import/exportを使いたいのでpackage.json"type": "module"を追加する

package.json
  "main": "src/index.js",
+ "type": "module",
qmotasqmotas

RESTDataSourceを継承してTrackAPIクラスを作っていく

server/src/track-api.js
import { RESTDataSource } from 'apollo-datasource-rest';

export class TrackAPI extends RESTDataSource {
  // ...
}

コンストラクタではAPIのベースURLをセットする

export class TrackAPI extends RESTDataSource {
  constructor() {
    super();
    this.baseURL = 'https://odyssey-lift-off-rest-api.herokuapp.com/';
  }
}
qmotasqmotas

RESTDataSource#get()でGETリクエストの処理を実装する

server/src/track-api.js
export class TrackAPI extends RESTDataSource {
  constructor() {
    super();
    this.baseURL = 'https://odyssey-lift-off-rest-api.herokuapp.com/';
  }

  getTracksForHome() {
    return this.get('tracks');
  }

  getAuthor(authorId) {
    return this.get(`author/${encodeURIComponent(authorId)}`);
  }
}
qmotasqmotas

Lift-off II: Resolvers - リゾルバの形 📖

https://www.apollographql.com/tutorials/lift-off-part2/the-shape-of-a-resolver

  • /server/src/resolvers.jsにリゾルバを実装していく
  • リゾルバは型またはフィールドをキーとしたオブジェクトとして作成する
/server/src/resolvers.js
const resolvers = {
  // implement here
};

export { resolvers }
qmotasqmotas

各フィールドに対応したリゾルバは当該フィールドのデータを収集する責務を負う
例えば、前のチュートリアルで定義したスキーマ

type Query {
  tracksForHome: [Track!]!
}

これに対応したリゾルバを実装するには

const resolvers = {
  Query: {
    tracksForHome: (parent, args, context, info) => {},
  }
}

こういうオブジェクトを作ることになる
各引数は

  • parent
    • フィールドの親リゾルバの結果
  • args
    • クエリの引数(idを指定して検索するような場合に使う)
      • Lift-off IIIでやる
  • context
    • すべてのリゾルバ間で横断的に共有されるオブジェクト
    • 認証情報やDBのコネクション、データソースなどをもつ
  • info
    • 処理の実行状態に関する情報
    • フィールド名とかrootから当該フィールドまでのパスとか
    • リゾルバでキャッシュを実装する場合に使うことがある
qmotasqmotas

Lift-off II: Resolvers - クエリのリゾルバを実装する ✍️

https://www.apollographql.com/tutorials/lift-off-part2/implementing-query-resolvers

qmotasqmotas

✍️ tracksForHomeクエリのリゾルバ

RESTDataSourceの実装で実装したデータソース(TrackAPI)でREST APIを叩いた結果を戻す

データソースのインスタンスはcontext.dataSources.trackAPIとして第3引数に渡されるので、これを使う

server/src/resolvers.js
const resolvers = {
  Query: {
    // トラックを全件抽出し、一覧ページに表示するデータを格納して戻す
    tracksForHome: (_, __, {dataSources}) => {
      return dataSources.trackAPI.getTracksForHome(); 
    }
  }
};
qmotasqmotas

ここで第一引数のparentが出てくる
authorは親のリゾルバであるtracksForHome()の結果(トラック情報の配列)の各要素に対して実行されるので、authorIdを取り出してAuthorを解決して戻す処理を実装する

server/src/resolvers.js
const resolvers = {
  Query: {
    tracksForHome: (_, __, { dataSources }) => {
      return dataSources.trackAPI.getTracksForHome();
    },
  },
  Track: {
    author: ({ authorId }, _, { dataSources }) => {
      return dataSources.trackAPI.getAuthor(authorId);
    },
  },
};

export { resolvers };
qmotasqmotas

Lift-off II: Resolvers - 点と点を繋いでいく ✍️

https://www.apollographql.com/tutorials/lift-off-part2/connecting-the-dots-in-server-land

これでスキーマ、データソース、リゾルバが用意できたので、これをくっつけて動かす

qmotasqmotas

✍️ モックをリゾルバに差し替える

サーバ起動時、モックデータを返すように設定していた部分をリゾルバに置き換える

server/src/index.js
import { resolvers } from './resolvers';

const server = new ApolloServer({
  typeDefs,
  resolvers,
});
qmotasqmotas

✍️ データソースを注入する

RESTDataSourceTrackAPI)のインスタンスを渡すことで、リゾルバからcontext経由で使えるようになる

server/src/index.js
const server = new ApolloServer({
  typeDefs,
  resolvers,
  dataSources: () => {
    return {
      trackAPI: new TrackAPI(),
    };
  },
});
qmotasqmotas

Lift-off II: Resolvers - 実データへのクエリを実行してみる ✍️

https://www.apollographql.com/tutorials/lift-off-part2/querying-live-data

サーバを起動し、Apollo Explorerからクエリを実行して挙動を確認する

qmotasqmotas
qmotasqmotas

Lift-off II: Resolvers - エラーハンドリング ✍️

https://www.apollographql.com/tutorials/lift-off-part2/errors-when-queries-go-sideways

  • サーバでクエリのバリデーションエラーが発生した場合など、GraphQL APIはその時点で処理をストップしてクライアントにエラーを返す
  • この時点ではスキーマに存在していないが、トラックの表示内容に追加したいフィールドとしてnumberOfViews(閲覧数、/tracksAPIのレスポンスに含まれている)をクエリに含めてみる

Apollo Studioではスキーマにないフィールドはその場で教えてくれる

qmotasqmotas

そのままクエリを実行してみるとレスポンスにerrorsとしてエラーの内容が格納されて返ってくる

qmotasqmotas
  • GraphQL APIは複数のエラーを返すことができるので、errorsは配列
  • ApolloServerのエラーコードは https://www.apollographql.com/docs/apollo-server/data/errors/#error-codes
  • ここではGRAPHQL_VALIDATION_FAILEDが発生している
    • クエリのバリデーションエラー
    • messageにどのフィールドでエラーが発生したか書かれている
  • クライアントではuseQuery()の戻り値からerrorsを取り出してハンドリングする
{
  "errors": [
    {
      "message": "Cannot query field \"numberOfViews\" on type \"Track\".",
      "extensions": {
        "code": "GRAPHQL_VALIDATION_FAILED",
        "exception": {
          "stacktrace": [
            "GraphQLError: Cannot query field \"numberOfViews\" on type \"Track\".",
            "...省略..."
          ]
        }
      }
    }
  ]
}
qmotasqmotas

Lift-off III: Arguments - 概要 📖 ✍️

https://www.apollographql.com/tutorials/lift-off-part3/feature-overview

  • ここまででトラック一覧の画面ができたので、次は各トラックの詳細を表示できるようにする
  • トラックのIDを引数で受け取り、トラックの詳細を戻すクエリを実装していく
    • これもスキーマファーストで、まずスキーマを追加してからリゾルバを修正していく
    • バックエンドができたらフロントエンドを実装する
qmotasqmotas

✍️ 前準備

チュートリアルのリポジトリをクローンし、クライアントとサーバの起動を確認する

git clone https://github.com/apollographql/odyssey-lift-off-part3

server/へ移動して

npm install && npm start

client/へ移動して

npm install && npm start
qmotasqmotas

Lift-off III: Arguments - スキーマの更新 📖 ✍️

https://www.apollographql.com/tutorials/lift-off-part3/updating-our-schema

📖 仕様

画面の設計から、スキーマに加える変更を考える

https://www.apollographql.com/tutorials/lift-off-part3/updating-our-schema

  • 詳細画面には、今のTrackのフィールドに加えて以下を表示したい
    • 説明文
    • 閲覧数
    • トラックに含まれるモジュールのリスト
  • モジュールごと、タイトルと長さ(時間)も表示したい
qmotasqmotas

✍️ スキーマを修正する

server/src/schema.jsを修正し、Trackにフィールドを追加する

server/src/schema.js
  type Track {
    id: ID!
    "The track's title"
    title: String!
    "The track's main Author"
    author: Author!
    "The track's illustration to display in track card or track page detail"
    thumbnail: String
    "The track's approximate length to complete, in minutes"
    length: Int
    "The number of modules this track contains"
    modulesCount: Int
+   "The track's complete description, can be in Markdown format"
+   description: String
+   "The number of times a track has been viewed"
+   numberOfViews: Int
+   "The track's complete array of Modules"
+   modules: [Module!]!
  }

モジュールの型を追加する

server/src/schema.js
"A Module is a single unit of teaching. Multiple Modules compose a Track"
type Module {
  id: ID!
  "The Module's title"
  title: String!
  "The Module's length in minutes"
  length: Int
}
qmotasqmotas

Lift-off III: Arguments - GraphQLの引数 📖 ✍️

https://www.apollographql.com/tutorials/lift-off-part3/graphql-arguments

  • 指定されたIDのトラックを抽出できるようにしたい
  • 単一のTrackを取得するクエリを追加する
qmotasqmotas

引数を受け取るクエリのスキーマはこんな感じ

missions(to: String, scheduled: Boolean): [Mission!]
  • TypeScriptの関数の書き方に似ている
  • 可変長引数はない(リストなら渡せる)
qmotasqmotas

✍️ 単一のトラックを取得するクエリの定義を追加する

  • IDを引数にとる
  • 単一のTrackを戻す
server/src/schema.js
  type Query {
    "Query to get tracks array for the homepage grid"
    tracksForHome: [Track!]!
+   "Fetch a specific track, provided a track's ID"
+   track(id: ID!): Track
  }
qmotasqmotas

Lift-off III: Arguments - リゾルバのargsパラメータ 📖 ✍️

https://www.apollographql.com/tutorials/lift-off-part3/resolver-args-parameter

  • クエリのスキーマができたのでリゾルバを実装していく
  • 単一のトラックの情報は引き続きREST APIを叩いて取得する
qmotasqmotas

📖 データの取得

https://odyssey-lift-off-rest-api.herokuapp.com/docs/#/Tracks/get_track__id_

  • GET track/:idでトラックの情報を取得する
  • リクエスト例: https://odyssey-lift-off-rest-api.herokuapp.com/track/c_0
  • レスポンス例
{
  "id": "c_0",
  "thumbnail": "https://res.cloudinary.com/dety84pbu/image/upload/v1598465568/nebula_cat_djkt9r.jpg",
  "topic": "Cat-stronomy",
  "authorId": "cat-1",
  "title": "Cat-stronomy, an introduction",
  "description": "Curious to learn what Cat-stronomy is all about? Explore the planetary and celestial alignments and how they have affected our space missions.",
  "numberOfViews": 28,
  "numberOfLikes": 0,
  "createdAt": "2018-09-10T07:13:53.020Z",
  "length": 2377,
  "modulesCount": 10,
  "modules": [
    "l_0",
    "l_1",
    "l_2",
    "l_3",
    "l_4",
    "l_5",
    "l_6",
    "l_7",
    "l_8",
    "l_9"
  ]
}
qmotasqmotas

✍️ RESTDataSourceに実装を追加する

server/src/datasources/track-api.jsGET track/:idを叩くメソッドを追加する

server/src/datasources/track-api.js
class TrackAPI extends RESTDataSource {
  constructor() {
    super();
    // the Catstronauts catalog is hosted on this server
    this.baseURL = 'https://odyssey-lift-off-rest-api.herokuapp.com/';
  }

  getTracksForHome() {
    return this.get('tracks');
  }

  getAuthor(authorId) {
    return this.get(`author/${authorId}`);
  }

+ getTrack(trackId) {
+   return this.get(`track/${trackId}`);
+ }
}
qmotasqmotas

✍️ リゾルバを追加する

server/src/resolvers.jstrackクエリのリゾルバを実装する

  • 第2引数(args)からidを取り出す
  • 一つ前で追加したRESTDataSource#getTrack()で取得した結果を戻す
server/src/resolvers.js
const resolvers = {
  Query: {
    // returns an array of Tracks that will be used to populate the homepage grid of our web client
    tracksForHome: (_, __, { dataSources }) => {
      return dataSources.trackAPI.getTracksForHome();
    },
+   // get a single track by ID, for the track page
+   track: (_, { id }, { dataSources }) => {
+     return dataSources.trackAPI.getTrack(id);
+   },
  },
  Track: {
    author: ({ authorId }, _, { dataSources }) => {
      return dataSources.trackAPI.getAuthor(authorId);
    },
  },
};
qmotasqmotas

Lift-off III: Arguments - Resolver chains 📖 ✍️

https://www.apollographql.com/tutorials/lift-off-part3/resolver-chains

  • トラックに紐づくモジュールのリゾルバは親リゾルバの結果を受け取って処理を行うよう実装する(Resolver chains
  • もうTrack.authorがそうやって実装されている
qmotasqmotas

📖 データの取得

https://odyssey-lift-off-rest-api.herokuapp.com/docs/#/Tracks/get_track__id_

  • GET track/:id/modulesでトラックに紐づくモジュールの情報を取得する
  • リクエスト例: https://odyssey-lift-off-rest-api.herokuapp.com/track/c_0/modules
  • レスポンス例
[
  {
    "id": "l_0",
    "title": "Exploring Time and Space",
    "trackId": "c_0",
    "authorId": "cat-1",
    "topic": "Cat-stronomy",
    "length": 258,
    "content": "# Procul spolioque nondum\n## Quis canibus\nLorem markdownum navis ab sibi aurum tantique tantusque quam clamorque radiis e\nnocte tutus. Fatorum nec vaga sinistrae silvis! Virgo nunc anxius strata, vos\nmore murmure! Ait et efflant cultos.\n> Ait at lanigerosve Caenis rigida, in ictus luctu quem. Arbitrium moribundo\n> cuncta. Exsul volucresque natos, aut nimia labitur.\nFlores vidi maxima et Booten, veluti Bybli, bis est? Indigetes huc illa habitant\nilla aequore vera? Esse unum undique, pedem flammaeque dedit bracchia equorum,\nsola deseruit temone: ab vidit, o cultu, errat? Et evanida terrore atra nati\nflorem fluminaque si solitum vulnus murmure? Scopulos atque, et quam fuitque\nmembra nobilitate populi.\n## Quo ramos adiectura\nEgo et et Leuconoe sole, an ceu ira caeli ab altera. Athos clamato amor sic\ncruorem Acheloe tantum solito, vir sonat fronde corpus, abit nocendo amplexus.\nFrondes in cervix communis certe aquarum, in atria, qui suus, quantaque tulit.\nExemplum ardentibus habebat: ac habebit spiritus non pater restituit vide. Oris\nore. Lacerti est lignum non. Est quodque querenda lenita, una Pario, vim inquit\ncingens Eridanus! Hac qui quaecumque tigno.\n## Piceae secunda gratamque haliaeetus proceres adsimulavit Ixiona\nQuas demissaque salutet habent aether, Latios pius Perseus adspicit, eris cruor\ninfelix ventis. Virgo tela solum, colebat ferat.\n- Nepotum nec Luna utilis\n- Pennis odit matres\n- Nec ad ursi est sequatur\n- Auras arbor qui monet relinquit natusque a\n- Diuque ignarus dextroque satyri\nNymphae Bubasides Cyclopum se frigore! Tandem saepe quoque virtute, ut et\nBacchica mensor, mea quae dedit, avidamque.",
    "videoUrl": "https://youtu.be/ImudUVWINXo"
  },
  ...
qmotasqmotas

✍️ RESTDataSourceに実装を追加する

server/src/datasources/track-api.jsGET track/:id/modulesを叩くメソッドを追加する

server/src/datasources/track-api.js
class TrackAPI extends RESTDataSource {
  constructor() {
    super();
    // the Catstronauts catalog is hosted on this server
    this.baseURL = 'https://odyssey-lift-off-rest-api.herokuapp.com/';
  }

  getTracksForHome() {
    return this.get('tracks');
  }

  getAuthor(authorId) {
    return this.get(`author/${authorId}`);
  }

  getTrack(trackId) {
    return this.get(`track/${trackId}`);
  }

+ getTrackModules(trackId) {
+   return this.get(`track/${trackId}/modules`);
+ }
}
qmotasqmotas

✍️ リゾルバを追加する

server/src/resolvers.jsTrack.modulesのリゾルバを実装する

  • 第1引数(parent)からidを取り出す
    • 第2引数(args)は使わない
    • あくまで親リゾルバにのみ依存する
  • 一つ前で追加したRESTDataSource#getTrackModules()で取得した結果を戻す
server/src/resolvers.js
const resolvers = {
  Query: {
    // returns an array of Tracks that will be used to populate the homepage grid of our web client
    tracksForHome: (_, __, { dataSources }) => {
      return dataSources.trackAPI.getTracksForHome();
    },
    // get a single track by ID, for the track page
    track: (_, { id }, { dataSources }) => {
      return dataSources.trackAPI.getTrack(id);
    },
  },
  Track: {
    author: ({ authorId }, _, { dataSources }) => {
      return dataSources.trackAPI.getAuthor(authorId);
    },
+   modules: ({ id }, _, { dataSources }) => {
+     return dataSources.trackAPI.getTrackModules(id);
+   },
  },
};
qmotasqmotas

Lift-off III: Arguments - クエリを組み立てる ✍️

https://www.apollographql.com/tutorials/lift-off-part3/query-building-in-apollo-sandbox

ブラウザで http://localhost:4000 にアクセスしてApollo Sandboxを立ち上げてクエリを作っていく

qmotasqmotas

ここでTips

サイドバーからSchemaを選択するとスキーマの情報を見やすい形で表示してくれる

qmotasqmotas
  • Apollo Explorerでtrackを追加するとクエリの雛形ができる(フィールドが未選択なのでエラー表示も)
  • GraphQLでは変数名に$trackIdのように$を接頭辞としてつける

qmotasqmotas
  • 引数の値を指定するときは画面下のVariablesに入力する

qmotasqmotas

必要なフィールドを列挙する前に、とりあえずidtitleだけ取得するクエリを作って実行してみる

query GetTrack($trackId: ID!) {
  track(id: $trackId) {
    id
    title
  }
}

ちゃんと結果が返ってきた

qmotasqmotas

Fieldsの...アイコンからSelect all fields recursivelyを選択するとすべてのフィールドを(入れ子になっているものも)クエリに追加できる

qmotasqmotas

クエリができた

query Track($trackId: ID!) {
  track(id: $trackId) {
    id
    title
    author {
      id
      name
      photo
    }
    thumbnail
    length
    modulesCount
    description
    numberOfViews
    modules {
      id
      title
      length
    }
  }
}
qmotasqmotas

Lift-off III: Arguments - トラックのページを作る ✍️

https://www.apollographql.com/tutorials/lift-off-part3/building-the-track-page

client/src/pages/track.jsを追加して、単一のトラックの内容を表示する画面を実装する

client/src/pages/track.js
import React from 'react';
import { gql, useQuery } from '@apollo/client';
import { Layout, QueryResult } from '../components';

// 雛形
// パスパラメータ(trackId)はコンポーネントのプロパティで受け取れる
export const Track = ({ trackId }) => {
  return <Layout></Layout>;
};
qmotasqmotas

✍️ ルーティングの追加

/track/:trackIdへのリクエストをtrack.jsのページで表示するためのルーティングを実装する

client/src/pages/index.js
  import React, { Fragment } from 'react';
  import { Router } from '@reach/router';
  /** importing our pages */
  import Tracks from './tracks';
+ import { Track } from './track';

  export default function Pages() {
    return (
      <Router primary={false} component={Fragment}>
        <Tracks path="/" />
+       <Track path="/track/:trackId" />
      </Router>
    );
  }
qmotasqmotas

✍️ クエリを実装する

  • track.jsの冒頭で、クエリをGET_TRACKとして実装する
  • クエリ名はGetTrackとする(前の手順でApollo Explorerで作ったときはTrackだったけど)
client/src/pages/track.js
const GET_TRACK = gql`
  query GetTrack($trackId: ID!) {
    track(id: $trackId) {
      id
      title
      author {
        id
        name
        photo
      }
      thumbnail
      length
      modulesCount
      description
      numberOfViews
      modules {
        id
        title
        length
      }
    }
  }
`;
qmotasqmotas

✍️ useQueryフックで実行結果を取得する

クエリの引数はuseQueryの第2引数でvariablesとして指定する

client/src/pages/track.js
  export const Track = ({ trackId }) => {
+   const { loading, error, data } = useQuery(GET_TRACK, {
+     variables: { trackId },
+   });

    return <Layout></Layout>;
  };
qmotasqmotas

✍️ ページの中身を実装する

  • クエリの結果を表示するコンポーネントをラップする<QueryResult>がすでに用意されているのでこれを使う
    • ローディング表示などを行ってくれる
  • トラックの内容を表示する<TrackDetail>も用意されている
client/src/pages/track.js
  import React from 'react';
  import { gql, useQuery } from '@apollo/client';
  import { Layout, QueryResult } from '../components';
+ import TrackDetail from '../components/track-detail';

  const GET_TRACK = gql`
    query GetTrack($trackId: ID!) {
      track(id: $trackId) {
        id
        title
        author {
          id
          name
          photo
        }
        thumbnail
        length
        modulesCount
        description
        numberOfViews
        modules {
          id
          title
          length
        }
      }
    }
  `;

  export const Track = ({ trackId }) => {
    const { loading, error, data } = useQuery(GET_TRACK, {
      variables: { trackId },
    });

-   return <Layout></Layout>;
+   return (
+     <Layout>
+       <QueryResult error={error} loading={loading} data={data}>
+         <TrackDetail track={data?.track} />
+       </QueryResult>
+     </Layout>
+   );
  };

qmotasqmotas

Lift-off III: Arguments - トラックページへの遷移 ✍️

https://www.apollographql.com/tutorials/lift-off-part3/navigating-to-the-track-page

トラック単体の内容を表示するページができたので、一覧画面から遷移できるようにする

qmotasqmotas
  • @reach/routerLinkコンポーネントを使う
  • 各トラックをカード表示しているCardContainerをクリックしたら遷移するようにしたい
    • CardContainerの実装を修正して、divだった部分をLinkに書き換える
client/src/containers/track-card.js
+ import { Link } from '@reach/router';

  // ...

- const CardContainer = styled('div')({
+ const CardContainer = styled(Link)({
    borderRadius: 6,
    color: colors.text,
    backgroundSize: 'cover',
    backgroundColor: 'white',
    // ...
qmotasqmotas
  • TrackCardの実装を修正して、リンク先のパスを<CardContainer>に渡すようにする
client/src/containers/track-card.js
  const TrackCard = ({ track }) => {
-   const { title, thumbnail, author, length, modulesCount } = track;
+   const { title, thumbnail, author, length, modulesCount, id } = track;

    return (
-     <CardContainer>
+     <CardContainer to={`/track/${id}`}>
        <CardContent>
qmotasqmotas

一覧のカードをクリックするとトラックページに遷移できるようになった

qmotasqmotas

Lift-off IV: Mutations - 概要 📖

https://www.apollographql.com/tutorials/lift-off-part4/feature-overview

  • GraphQLでデータの更新(mutation)を行う
  • トラックページを表示したとき、閲覧数を更新する機能を追加する
qmotasqmotas

✍️ 前準備

チュートリアルのリポジトリをクローンし、クライアントとサーバの起動を確認する

git clone https://github.com/apollographql/odyssey-lift-off-part4

server/へ移動して

npm install && npm start

client/へ移動して

npm install && npm start
qmotasqmotas

Lift-off IV: Mutations - mutationとは

https://www.apollographql.com/tutorials/lift-off-part4/what-is-a-mutation

  • GraphQLにおけるmutations(ミューテーション、変異)は更新の操作を指す
  • Query型のように、Mutation型をスキーマに定義する
qmotasqmotas

📖 スキーマの構文

  • 型名はMutation
  • 中身はクエリと同様
  • adddeletecreateのように更新の種類がわかるような命名がよい
type Mutation {
  addSpacecat(name: String!): Spacecat
}