Apollo OdysseyでGraphQL(のApolloによる実装)を完全に理解したい
概要
GraphQLを体系的に習得するのによいドキュメント・チュートリアルはないかと探したところ、Apolloが公式で提供しているApollo Odysseyがよさそうだったので基礎部分をやってみる
全体的に説明 -> 手を動かすという構成になっているので、このスクラップでは分かりやすいよう見出しに絵文字をつける
- 📖 説明
- ✍️ 手を動かすもの
このログをチームで共有してやってもらうことも考えているので、チュートリアルの日本語訳を箇条書きにしながら手を動かしていく
内容は省略や意訳、個人的な意見を書くことがあるので参照する際は注意されたい
Odysseyのサイトでサインアップすると進捗が記録される
Node.jsのバージョンが16前提のようなので、17以上を使っているとところどころエラーが発生する可能性がある
全体的に、実開発ではTypeScriptを使ってESModule形式でモジュールを参照することを想定しているため、import/exportで書き直している
チュートリアルが進むと予め用意されているコードもimport/exportに書き換えないと動かないため、面倒ならCommonJS形式で進めるのがよい
開始時点のスキル
- GraphQLについてはなんとなくの知識がある程度で、クライアントもサーバも実装したことはない
- なんとなくの理解
- 単一のエンドポイントにクエリを投げて指定した形式でレスポンスを返してくれる
- スキーマ駆動で、コード生成によって様々な言語で型安全にクライアントを実装できる
- スキーマはグラフ構造で、1回のクエリで入れ子のデータをガッと取得できる
- GraphQL Federationで複数のサービス(エンドポイント)を集約できる
- GraphQLのマイクロサービスに対するBFF
- GraphQL Federationで複数のサービス(エンドポイント)を集約できる
- TypeScript、Next.js、React.js、Recoil.jsでアプリをスクラッチ開発したことがある
- バックエンドはNext.jsのAPI Routesでzodスキーマ/型定義をフロントエンドと共有
📖 Apollo Odysseyとは
https://www.apollographql.com/tutorials/のWelcome to Apollo Odysseyを翻訳
OdysseyはApolloの公式学習プラットフォームで、無料のハンズオンGraphQLチュートリアルを提供しています。GraphQLの旅を始めるには最適な場所です。Odysseyのコースは、自分のスケジュールに合わせて完了できる、短くて簡潔なレッスンの集合体です。各レッスンには、ビデオとそれに対応する文章が付属しています。さらに、私たちのGraphQLチュートリアルは、コードチャレンジ、タスク、クイッククイズで充実しており、インタラクティブな体験を提供し、知識を強化することができます。
各チュートリアルについて
List-offが基礎、Side Questは関連知識、Voyageは発展という感じ
とりあえず以下をやる
- Lift-off I: Basics
- Lift-off II: Resolvers
- Lift-off III: Arguments
- Lift-off IV: Mutations
- Lift-off V: Production & the Schema Registry
- Side Quest: Authentication & Authorization
Side Quest: Authentication & Authorizationは実用上必須だと思うのでやる
Lift-off I: Basics - チュートリアルの概要と準備 ✍️ 📖
📖 概要
- 題材として、CatstronautsというフルスタックのGraphQLアプリを作っていく
- 宇宙飛行士の猫向けの学習プラットフォーム
- まずはモックデータを返すGraphQL APIを実装してスキーマの定義を学ぶ
- スキーマファーストな開発の典型的な流れ
- スキーマを定義する
- 機能が必要とするデータを特定し、データをできるだけ直感的に提供できるようスキーマを構造化する
- バックエンド実装
- Apollo ServerでGraphQL APIを実装し、求められたデータをあらゆるデータソースからフェッチする(ここではモックデータを使う)
- 後続のコースではREST APIに接続する
- フロントエンド実装
- GraphQL APIから取得したデータをビューに描画する
- スキーマファーストな設計の利点として、フロントエンドとバックエンドの開発を並行することで開発に要する時間を削減できるというのがある
- フロントエンドチームはスキーマさえあればモックデータで開発をスタートできる
- バックエンドチームも同時にスキーマに基づいてAPIを実装できる
- これがGraphQL API設計の唯一の手段ではないが、(チュートリアル提供元のApolloは)効果的であると確信している
チュートリアルの前提条件
- バックエンドはNode.js
- フロントエンドはReact.js
-
import
、map
、async
、jsx
といったキーワードやそのコンセプト、React Hooksに慣れていること
✍️ 準備
✍️ リポジトリをクローンする
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
Chromeが自動で起動して空のアプリが表示されればOK
Lift-off I: Basics - データの要件 📖
デザインのモック
https://www.apollographql.com/tutorials/lift-off-part1/feature-data-requirements
講習のトラックごとカード表示したい
トラックカード単位のデータ構造
- タイトル
- サムネイル
- トラックの時間(動画の長さとか、受講時間とかのイメージだと思う)
- モジュール数(なんだろう、ここのコメントを後で補完する)
- 作者名
- 作者画像
📖 グラフ
- 前述のデータ構造をオブジェクトの集合として考える
- 各オブジェクトをノード、ノード間の関連をエッジと呼ぶ
- ノードとエッジで構成されたものをグラフと呼ぶ
- -> グラフ理論
- GraphQLではデータ構造をグラフで表現する
- クエリも型として定義するので、すべてがグラフになる
トラックのデータ構造をグラフで描いた図
https://www.apollographql.com/tutorials/lift-off-part1/feature-data-requirements
Lift-off I: Basics - スキーマ定義言語(SDL) 📖
-
スキーマはサーバとクライアント間の契約書のようなもの
- APIが何をできて何をできないかということを定義する
- スキーマはSDLと呼ばれる専用の言語で定義する
- Schema Definition Language
type SpaceCat {
name: String!
age: Int
missions: [Mission]
}
のように書く
📖 型定義
- JavaScriptのオブジェクトリテラルやTypeScriptの型エイリアスに似ているが、行末のセミコロンやカンマはない
- 型は後置で、
String
やInt
のようなプリミティブな型はスカラー型という- デフォルトで
String
、Int
、Float
、Boolean
、ID
がある - 任意のスカラー型を定義することも可能
- デフォルトで
- non-nullなフィールドは
String!
のように型名に!
をつける - リストは
[String]
のように書く- リストそのものも要素もnon-nullなら
[String!]!
- リストそのものも要素もnon-nullなら
📖 description
"""graphql
オブジェクトの説明など
ブロック記法
"""
type SpaceCat {
"フィールドの説明など"
name: String!
# これはコメント
age: Int
}
- オブジェクトやフィールドの説明を書くことができる
- コメントとは異なる
- descriptionはスキーマから生成されたコードやドキュメントに出力される
Lift-off I: Basics - スキーマの実装 ✍️
✍️ スキーマの実装
- バックエンド側でスキーマを定義する
- スキーマはJavaScriptでタグ付きテンプレートリテラルを用いて埋め込む
server/
ディレクトリで依存パッケージをインストール
npm install apollo-server graphql
server/src/schema.js
を追加して、スキーマ定義用の関数をimportする
import { gql } from 'apollo-server';
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
}
author
はAuthor
型(次に定義する)
Author
の型定義
"Author of a complete Track or a Module"
type Author {
id: ID!
name: String!
photo: String
}
Query
の型定義
データ構造に対するクエリも型として定義する
モック画面に表示するTrack
の一覧を取得するクエリは例えば以下のように定義される
type Query {
"Get tracks array for homepage grid"
tracksForHome: [Track!]!
}
schema.js
の実装
✍️ 以上を踏まえて、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 };
Lift-off I: Basics - Apollo Server ✍️
✍️ バックエンドアプリの実装
apollo-server
で、GraphQLサーバを動かす
以下のような機能をもつ
- 単一のAPIエンドポイントをもつHTTPサーバとして動作し、GraphQLクエリを受け付ける
- クエリのバリデーションを行う
- スキーマに基づいてモックデータを返す(デフォルトでそれっぽい値を生成してくれる)
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
Lift-off I: Basics - Apollo Explorer ✍️
- Apollo Explorerというツールでクエリの作成や実行が行える
-
Apollo StudioのApollo Sandboxで動く
- 要アカウント作成
- サーバを起動し、ブラウザで http://localhost:4000 を開くと下のようなページが表示されるので
Query your server
ボタンを押すとApollo Studioが開く
- 左ペインでクエリに含めたいフィールドを選択すると中央ペインにクエリが生成される
- 中央ペイン上の青いボタンでクエリを実行
- 右ペインにレスポンスが表示される
キャプチャはLift-off IIで実装したときのもの
Lift-off I: Basics - フロントエンド ✍️
client/
のディレクトリ構造
📂 client
┣ 📂 src
┃ ┣ 📂 assets
┃ ┣ 📂 components
┃ ┣ 📂 containers
┃ ┣ 📂 pages
┃ ┣ ...
┣ ...
-
pages/
とcontainers/
が主な実装範囲 -
components/
にはカード表示のコンポーネントなど、チュートリアルで使うコンポーネントが実装済みなので今回は特に触れない - Apollo Clientを使ってGraphQLのクエリを投げていく
client/
に移動してnpm start
すると、http://localhost:3000 で開ける
ソースを変更して保存すると自動で画面が更新され、変更が反映される
Lift-off I: Basics - Apollo Clientのセットアップ ✍️
-
grqphql
パッケージと@apollo/client
パッケージを使う
client/
ディレクトリへ移動して必要なパッケージをインストールする
npm install graphql @apollo/client
client/src/index.js
でクライアントのパッケージをimportして実装していく
import { ApolloProvider, ApolloClient, InMemoryCache } from '@apollo/client';
- クライアントは
ApolloClient
クラスのコンストラクタにオプションを渡して作成する-
uri
はローカルで起動したサーバのhttp://localhost:4000
-
cache
にはInMemoryCache
のインスタンス
-
const client = new ApolloClient({
uri: 'http://localhost:4000',
cache: new InMemoryCache(),
});
Apollo Clientの機能をUIコンポーネントから使う(Context API)ため、<ApolloProvider>
でトップレベルのコンポーネントをラップする
ReactDOM.render(
<ApolloProvider client={client}>
<GlobalStyles />
<Pages />
</ApolloProvider>,
document.getElementById('root')
);
こうすることで、配下の任意のコンポーネントからApollo Clientのカスタムフック経由で必要なデータにアクセスできるようになる
最終形
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')
);
Lift-off I: Basics - クエリを定義する ✍️
- クライアントの用意ができたので、実際に投げるクエリを書いていく
- クエリは
gql()
関数にタグ付きテンプレートリテラルで渡す - クエリの変数名は
UPPER_SNAKE_CASE
にする
const TRACKS = gql`
# ここにGraphQLクエリを書く
`
TRACKSの実装
const TRACKS = gql`
query getTracks {
tracksForHome {
id
title
thumbnail
length
modulesCount
author {
name
photo
}
}
}
`;
Lift-off I: Basics - useQueryフック ✍️
Reactアプリケーションからgql()
関数で定義したクエリを実行して結果を得るにはuseQuery
フックを利用する
import { useQuery, gql } from '@apollo/client';
const TRACKS = gql`
// ...
`;
const Tracks = () => {
const { loading, error, data } = useQuery(TRACKS);
// ...
}
- クエリの実行は非同期だけど同期処理でラップしている
- Recoil.jsのloadableと同じパターン
- クエリの実行中は
loading
がtrue
になるので、これを条件にして読込中の表示にフォールバックする - クエリの実行が完了すると
data
に結果のオブジェクトが入る- エラーがある場合は
error
に内容が入る
- エラーがある場合は
<Track>
コンポーネントを実装する
✍️ トラックの一覧を表示するまずは素朴に実装してみる
- 読込中ならメッセージを表示
- エラーがあったらエラーメッセージを表示
- 正常に結果が取得できたら内容をJSON文字列として表示
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文字列がずらっと表示される
<TrackCard>
コンポーネントを使う
✍️ トラックの内容を表示するチュートリアルのプロジェクトにはclient/src/containers/track-card.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>
+ );
};
これでこんなふうに表示されるようになる
✍️ 一覧表示をラップする
- このままだと画面表示時に一瞬
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;
}
};
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>
);
};
一覧表示前にスピナーが表示されるようになった
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;
Lift-off II: Resolvers - GraphQLクエリの道のり ✍️ 📖
- Lift-off IIではGraphQLのリゾルバについて学習する
- Lift-off Iで作った一覧画面の中身を実装する
- 今度はモックではなく、実際に動いているREST APIからデータを取得する
- https://odyssey-lift-off-rest-api.herokuapp.com/
✍️ 前準備
チュートリアルのリポジトリをクローンし、クライアントとサーバの起動を確認する
やることはLift-off Iと同じ
git clone https://github.com/apollographql/odyssey-lift-off-part2
server/
へ移動して
npm install && npm start
client/
へ移動して
npm install && npm start
📖 GraphQLクエリの道のり
- クライアント
- HTTPリクエストでクエリを送信する
-
POST
またはGET
-
- HTTPリクエストでクエリを送信する
- サーバ
- スキーマに基づいてクエリのパースとバリデーションを行う
- クエリはAST(Abstract Syntax Tree: 抽象構文木)に変換される
- ASTを走査(walk)しながら、各フィールドに対してリゾルバを実行し、結果をマップする
- リゾルバは指定されたフィールドの値を取得して戻す関数
- ASTの各ノードを対応したリゾルバで変換した写像を結果とする、と言える
- HTTPレスポンスボディに
data
をキーとして結果を格納して返す
- クライアント
- クエリの結果を使ってレンダリング
Lift-off II: Resolvers - Exploring our data 📖
- リゾルバがデータを収集する元ネタをデータソースと呼ぶ
- DB、サードパーティAPI、ウェブフック、その他もろもろ
- このチュートリアルではOdyssey用にホスティングされたREST APIをデータソースとする
📖 データ構造について考える
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"]
},
{...},
]
スキーマはこうだった
type Track {
id: ID!
title: String!
author: Author!
thumbnail: String
length: Int
modulesCount: Int
}
id
thumbnail
title
modulesCount
length
は、REST APIのレスポンスからそのまま取得できそう
その他、topic
、description
等不要なプロパティもあるがこれは無視すればヨシ
author
に対応する詳細なデータ(name
など)は含まれていないので、authorId
を使って/author/:id
からデータを取得する
{
"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
}
Lift-off II: Resolvers - Apollo RESTDataSource 📖
データの場所と構造について理解したところで、リゾルバからのアクセスについて考える
- 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の実行結果をキャッシュすることで不要なアクセスを削減できそう
- データソースとして使うREST APIではキャッシュ設定のヘッダをつけてある、便利
- GraphQLでは、1つのクエリを複数の異なるキャッシュポリシーをもつエンドポイントから、異なるフィールドや型で構成することが多い
- キャッシュのやり方を考えないといけない
そこで、GraphQLでREST APIの呼び出しをいい感じでキャッシュ・重複排除してくれる仕組みとしてRESTDataSource
が用意されている
apollo-datasource-rest
をインストールする
npm install apollo-datasource-rest
import/exportを使いたいのでpackage.json
に"type": "module"
を追加する
"main": "src/index.js",
+ "type": "module",
RESTDataSource
を継承してTrackAPI
クラスを作っていく
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/';
}
}
RESTDataSource#get()
でGETリクエストの処理を実装する
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)}`);
}
}
Lift-off II: Resolvers - リゾルバの形 📖
-
/server/src/resolvers.js
にリゾルバを実装していく - リゾルバは型またはフィールドをキーとしたオブジェクトとして作成する
const resolvers = {
// implement here
};
export { resolvers }
各フィールドに対応したリゾルバは当該フィールドのデータを収集する責務を負う
例えば、前のチュートリアルで定義したスキーマ
type Query {
tracksForHome: [Track!]!
}
これに対応したリゾルバを実装するには
const resolvers = {
Query: {
tracksForHome: (parent, args, context, info) => {},
}
}
こういうオブジェクトを作ることになる
各引数は
-
parent
- フィールドの親リゾルバの結果
-
args
- クエリの引数(idを指定して検索するような場合に使う)
- Lift-off IIIでやる
- クエリの引数(idを指定して検索するような場合に使う)
-
context
- すべてのリゾルバ間で横断的に共有されるオブジェクト
- 認証情報やDBのコネクション、データソースなどをもつ
- データソースは
ApolloServer
の初期化時に注入する
- データソースは
-
info
- 処理の実行状態に関する情報
- フィールド名とかrootから当該フィールドまでのパスとか
- リゾルバでキャッシュを実装する場合に使うことがある
Lift-off II: Resolvers - クエリのリゾルバを実装する ✍️
tracksForHome
クエリのリゾルバ
✍️ RESTDataSourceの実装で実装したデータソース(TrackAPI
)でREST APIを叩いた結果を戻す
データソースのインスタンスはcontext.dataSources.trackAPI
として第3引数に渡されるので、これを使う
const resolvers = {
Query: {
// トラックを全件抽出し、一覧ページに表示するデータを格納して戻す
tracksForHome: (_, __, {dataSources}) => {
return dataSources.trackAPI.getTracksForHome();
}
}
};
ここで第一引数のparent
が出てくる
author
は親のリゾルバであるtracksForHome()
の結果(トラック情報の配列)の各要素に対して実行されるので、authorId
を取り出してAuthor
を解決して戻す処理を実装する
const resolvers = {
Query: {
tracksForHome: (_, __, { dataSources }) => {
return dataSources.trackAPI.getTracksForHome();
},
},
Track: {
author: ({ authorId }, _, { dataSources }) => {
return dataSources.trackAPI.getAuthor(authorId);
},
},
};
export { resolvers };
Lift-off II: Resolvers - 点と点を繋いでいく ✍️
これでスキーマ、データソース、リゾルバが用意できたので、これをくっつけて動かす
✍️ モックをリゾルバに差し替える
サーバ起動時、モックデータを返すように設定していた部分をリゾルバに置き換える
import { resolvers } from './resolvers';
const server = new ApolloServer({
typeDefs,
resolvers,
});
✍️ データソースを注入する
RESTDataSource
(TrackAPI
)のインスタンスを渡すことで、リゾルバからcontext
経由で使えるようになる
const server = new ApolloServer({
typeDefs,
resolvers,
dataSources: () => {
return {
trackAPI: new TrackAPI(),
};
},
});
Lift-off II: Resolvers - 実データへのクエリを実行してみる ✍️
サーバを起動し、Apollo Explorerからクエリを実行して挙動を確認する
-
server/
へ移動しnpm start
する - http://localhost:4000 へアクセス
-
Query your server
ボタンを押してApollo Studioを起動する
Lift-off II: Resolvers - エラーハンドリング ✍️
- サーバでクエリのバリデーションエラーが発生した場合など、GraphQL APIはその時点で処理をストップしてクライアントにエラーを返す
- この時点ではスキーマに存在していないが、トラックの表示内容に追加したいフィールドとして
numberOfViews
(閲覧数、/tracks
APIのレスポンスに含まれている)をクエリに含めてみる
Apollo Studioではスキーマにないフィールドはその場で教えてくれる
そのままクエリを実行してみるとレスポンスにerrors
としてエラーの内容が格納されて返ってくる
- 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\".",
"...省略..."
]
}
}
}
]
}
Lift-off II: Resolvers - 旅の終わり 🎉
クライアントとサーバをそれぞれnpm start
で起動して http://localhost:3000 にアクセスすると、http://localhost:4000 のGraphQL API経由で取得したREST APIの結果が一覧で表示される
Lift-off III: Arguments - 概要 📖 ✍️
- ここまででトラック一覧の画面ができたので、次は各トラックの詳細を表示できるようにする
- トラックのIDを引数で受け取り、トラックの詳細を戻すクエリを実装していく
- これもスキーマファーストで、まずスキーマを追加してからリゾルバを修正していく
- バックエンドができたらフロントエンドを実装する
✍️ 前準備
チュートリアルのリポジトリをクローンし、クライアントとサーバの起動を確認する
git clone https://github.com/apollographql/odyssey-lift-off-part3
server/
へ移動して
npm install && npm start
client/
へ移動して
npm install && npm start
- http://localhost:3000/ で一覧が表示されることを確認する
- http://localhost:4000/ からApollo Explorerを起動する
Lift-off III: Arguments - スキーマの更新 📖 ✍️
📖 仕様
画面の設計から、スキーマに加える変更を考える
https://www.apollographql.com/tutorials/lift-off-part3/updating-our-schema
- 詳細画面には、今のTrackのフィールドに加えて以下を表示したい
- 説明文
- 閲覧数
- トラックに含まれるモジュールのリスト
- モジュールごと、タイトルと長さ(時間)も表示したい
✍️ スキーマを修正する
server/src/schema.js
を修正し、Track
にフィールドを追加する
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!]!
}
モジュールの型を追加する
"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
}
Lift-off III: Arguments - GraphQLの引数 📖 ✍️
- 指定されたIDのトラックを抽出できるようにしたい
- 単一の
Track
を取得するクエリを追加する
引数を受け取るクエリのスキーマはこんな感じ
missions(to: String, scheduled: Boolean): [Mission!]
- TypeScriptの関数の書き方に似ている
- 可変長引数はない(リストなら渡せる)
✍️ 単一のトラックを取得するクエリの定義を追加する
- IDを引数にとる
- 単一の
Track
を戻す
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
}
args
パラメータ 📖 ✍️
Lift-off III: Arguments - リゾルバの
- クエリのスキーマができたのでリゾルバを実装していく
- 単一のトラックの情報は引き続きREST APIを叩いて取得する
📖 データの取得
-
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"
]
}
RESTDataSource
に実装を追加する
✍️ server/src/datasources/track-api.js
にGET track/:id
を叩くメソッドを追加する
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}`);
+ }
}
✍️ リゾルバを追加する
server/src/resolvers.js
にtrack
クエリのリゾルバを実装する
- 第2引数(
args
)からid
を取り出す - 一つ前で追加した
RESTDataSource#getTrack()
で取得した結果を戻す
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);
},
},
};
Lift-off III: Arguments - Resolver chains 📖 ✍️
- トラックに紐づくモジュールのリゾルバは親リゾルバの結果を受け取って処理を行うよう実装する(Resolver chains)
- もう
Track.author
がそうやって実装されている
📖 データの取得
-
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"
},
...
RESTDataSource
に実装を追加する
✍️ server/src/datasources/track-api.js
にGET track/:id/modules
を叩くメソッドを追加する
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`);
+ }
}
✍️ リゾルバを追加する
server/src/resolvers.js
にTrack.modules
のリゾルバを実装する
- 第1引数(
parent
)からid
を取り出す- 第2引数(
args
)は使わない - あくまで親リゾルバにのみ依存する
- 第2引数(
- 一つ前で追加した
RESTDataSource#getTrackModules()
で取得した結果を戻す
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);
+ },
},
};
Lift-off III: Arguments - クエリを組み立てる ✍️
ブラウザで http://localhost:4000 にアクセスしてApollo Sandboxを立ち上げてクエリを作っていく
ここでTips
サイドバーからSchemaを選択するとスキーマの情報を見やすい形で表示してくれる
- Apollo Explorerで
track
を追加するとクエリの雛形ができる(フィールドが未選択なのでエラー表示も) - GraphQLでは変数名に
$trackId
のように$
を接頭辞としてつける
- 引数の値を指定するときは画面下の
Variables
に入力する
必要なフィールドを列挙する前に、とりあえずid
とtitle
だけ取得するクエリを作って実行してみる
query GetTrack($trackId: ID!) {
track(id: $trackId) {
id
title
}
}
ちゃんと結果が返ってきた
Fields
の...アイコンからSelect all fields recursivelyを選択するとすべてのフィールドを(入れ子になっているものも)クエリに追加できる
クエリができた
query Track($trackId: ID!) {
track(id: $trackId) {
id
title
author {
id
name
photo
}
thumbnail
length
modulesCount
description
numberOfViews
modules {
id
title
length
}
}
}
Lift-off III: Arguments - トラックのページを作る ✍️
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>;
};
✍️ ルーティングの追加
/track/:trackId
へのリクエストをtrack.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>
);
}
ブラウザで http://localhost:3000/track/c_0 を開くと空のページが表示されるようになった
✍️ クエリを実装する
-
track.js
の冒頭で、クエリをGET_TRACK
として実装する - クエリ名は
GetTrack
とする(前の手順でApollo Explorerで作ったときはTrack
だったけど)
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
}
}
}
`;
useQuery
フックで実行結果を取得する
✍️ クエリの引数はuseQueryの
第2引数でvariables
として指定する
export const Track = ({ trackId }) => {
+ const { loading, error, data } = useQuery(GET_TRACK, {
+ variables: { trackId },
+ });
return <Layout></Layout>;
};
✍️ ページの中身を実装する
- クエリの結果を表示するコンポーネントをラップする
<QueryResult>
がすでに用意されているのでこれを使う- ローディング表示などを行ってくれる
- トラックの内容を表示する
<TrackDetail>
も用意されている
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>
+ );
};
ブラウザで再度 http://localhost:3000/track/c_0 を開くと詳細が表示されるようになった
Lift-off III: Arguments - トラックページへの遷移 ✍️
トラック単体の内容を表示するページができたので、一覧画面から遷移できるようにする
-
@reach/router
のLink
コンポーネントを使う - 各トラックをカード表示している
CardContainer
をクリックしたら遷移するようにしたい-
CardContainer
の実装を修正して、div
だった部分をLink
に書き換える
-
+ import { Link } from '@reach/router';
// ...
- const CardContainer = styled('div')({
+ const CardContainer = styled(Link)({
borderRadius: 6,
color: colors.text,
backgroundSize: 'cover',
backgroundColor: 'white',
// ...
-
TrackCard
の実装を修正して、リンク先のパスを<CardContainer>
に渡すようにする
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>
一覧のカードをクリックするとトラックページに遷移できるようになった
Lift-off IV: Mutations - 概要 📖
- GraphQLでデータの更新(mutation)を行う
- トラックページを表示したとき、閲覧数を更新する機能を追加する
✍️ 前準備
チュートリアルのリポジトリをクローンし、クライアントとサーバの起動を確認する
git clone https://github.com/apollographql/odyssey-lift-off-part4
server/
へ移動して
npm install && npm start
client/
へ移動して
npm install && npm start
- http://localhost:3000/ で一覧が表示されることを確認する
- http://localhost:4000/ からApollo Explorerを起動する
Lift-off IV: Mutations - mutationとは
- GraphQLにおけるmutations(ミューテーション、変異)は更新の操作を指す
-
Query
型のように、Mutation
型をスキーマに定義する
📖 スキーマの構文
- 型名は
Mutation
- 中身はクエリと同様
-
add
、delete
、create
のように更新の種類がわかるような命名がよい
type Mutation {
addSpacecat(name: String!): Spacecat
}
Lift-off IV: Mutations - スキーマにMutationを追加する
Lift-off IV: Mutations - Track APIのデータソースを修正する
Lift-off IV: Mutations - Mutationのリゾルバを実装する(正常系)
Lift-off IV: Mutations - Mutationのリゾルバを実装する(異常系)
Lift-off IV: Mutations - Apollo ExplorerでMutationをテストする
useMutation
フック
Lift-off IV: Mutations -
Lift-off IV: Mutations - ブラウザからMutationの様子を確認する
Lift-off V: Production & the Schema Registry - 概要と準備
Lift-off V: Production & the Schema Registry - schema registryとは
Lift-off V: Production & the Schema Registry - スキーマを登録する
Lift-off V: Production & the Schema Registry - Apollo Serverのデプロイ
Railwayにデプロイする
Lift-off V: Production & the Schema Registry - Apollo Clientのデプロイ
Lift-off V: Production & the Schema Registry - フィールドの非推奨化
Lift-off V: Production & the Schema Registry - レジストリ上のスキーマ変更